The AsyncLocalStorage guard refused a store's change handler or timer
writing back to its cache without the store waiting for it, which only
queues, and still missed a wait that escapes the async context. The serial
queue and its own-promise chain stay.
The storePushThatAwaitsItsOwnCacheIsRefusedInsteadOfHanging test is
replaced by storeChangeHandlerWritingBackQueuesAfterTheOperation: a store
push emits an event whose handler puts without being awaited; both writes
land in order and store and cache agree.
The contract's Locking section states the rule for all runtimes: a store
call must not wait for a write to its own cache; unawaited write-backs
queue. Python refuses a same-thread re-entrant mutation; no runtime detects
a wait on another thread or async context. The globals row states the
two-step legacy retirement with compensation, and that a crash or double
failure leaving both records is refused at the next pull.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The default CacheBackingStore::push_all deleted every row and then pushed
one by one, so a failing push left the store emptied or partial while
put_prepared_batch claimed all-or-nothing. The default is deleted:
push_all is a required method documented as one atomic replace, and a
compile_fail doctest pins that a store without it does not compile.
Every in-tree implementor (single-file, both redb stores, the test store,
Epiphany's runtime store) already supplies its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The serial chain now advances on its own promise and returns the
operation's promise untouched, so an unawaited failing put is an
unhandled rejection again instead of being swallowed by the chain's
catch.
A queued call started from inside a store call, decoder, accessor or
updater of the same cache's running operation throws at entry instead of
waiting on itself. Detection is an AsyncLocalStorage list of running
operations, so independent concurrent callers are still queued.
A pull adopts a global persisted under a key other than __global__ as
that type's global without writing. The first write or delete of it
removes the legacy record in the same call; a failed removal takes the
new record back out. Two globals of one type on disk, under any keys,
are still refused.
concurrentGlobalPutsCannotBothReachDisk now pauses inside push and fails
when the chain is replaced by Promise.resolve().then(operation).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Attach, pull, registration, writes and deletes raise when started on a
thread already inside a mutation of the same cache (a store call,
decoder, encoder, extractor or updater), instead of running inside it
and corrupting the state being built. Reads stay reentrant. Internal
delegation (put_global, update, delete_global, register_registry,
add_backing_store) goes through private helpers so it is not re-entry.
A pull adopts a global persisted under a key other than __global__ as
that type's global without writing. The first write or delete of it
removes the legacy record in the same call; a failed removal takes the
new record back out. Two globals of one type on disk are still refused.
The lock and unlocked-read comments now state the per-call guarantee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Python cache took no lock, so "refused write changes neither store nor
cache" and the one-home rule held only for one caller at a time. A put paused
between its store push and its apply let a concurrent attach route the type to
a new home (the record stayed in the old store), and let a second global put
reach disk first so cache and disk disagreed.
CultCache now holds a threading.RLock around attach, pull, registration, every
write and delete (globals and update included), name/index resolution and
snapshots, matching the C# per-cache gate and the TS serial queue. Single-dict
reads stay unlocked. No public API change.
Tests: test_concurrent_put_and_attach_cannot_land_record_in_non_home_store and
test_concurrent_global_puts_cannot_both_reach_disk force the interleaving with
a store that pauses inside push; both fail on 4dc7f3a and pass here. The
contract's Python cells drop the one-caller caveat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
put_prepared_batch refuses an empty key or stored_at before any store is
touched. Its one-home check covers the staged records only, and the home store
receives only its own records (those this cache holds for it plus the staged
ones), so a single-home batch is no longer refused because other homes hold
records.
load_soa builds the next view in a local map, refusing an unregistered type or,
once a store is attached, a type with no home store; the view is replaced in one
step, so a refused load leaves the prior view intact.
load_envelope is deleted: it had no callers and admitted records into the view
without a store.
Tests: refuse-at-load and pull start from a non-empty cache and assert prior
state intact; atomic load_soa; single-home batch beside other homes' records;
batch with empty key or stored_at.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A per-cache promise chain now runs addBackingStore, pullAllBackingStores,
registration and every write and delete one at a time, so validation, the
store call and applying to the cache cannot interleave with another mutation.
Those methods return promises; the builder configures its unreleased cache
synchronously. Reads stay synchronous.
put and putEnvelope resolve the home, check the global key and run the name and
index accessors before the store is touched; applying afterwards has no
fallible step. Each held record keeps the lookup values it produced at
admission, so overwrite and delete run no accessor. A global is only stored
under __global__: any other key is refused, and replacing the global writes
__global__ with no delete-then-push. registerDocumentType, registerNameLookup
and registerIndex check schema-id and schema-name conflicts and derive every
lookup before installing anything.
cultmesh-ts awaits registerDocumentType in createNode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
put, put_envelope and put_envelopes now compute the new records' name and
index keys and resolve the home store before any push; applying to the cache
afterwards runs no extractor. Each held record keeps the lookup keys it
produced at admission, so overwrite and delete remove lookups without calling
user extractors. put_envelope and put_envelopes refuse an empty key and a
global under any key but __global__. Per-type dicts are created only after the
store accepts. register_document_type, register_name_lookup and register_index
derive every lookup under the candidate extractors first and install
extractors and lookups together, so a raising extractor installs nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pull is all-or-nothing, but TS cleared the cache and then ran user name and
index accessors while re-applying entries, and Python swapped values in and
then rebuilt lookups. An accessor that threw left the cache partly loaded.
TS: the hydrated maps (entries, type entry ids, name and index lookups, global
keys) are one HydratedState. The pull builds a fresh one, running every check
and accessor, and replaces #hydrated in one assignment at the end. The
reset-then-apply sequence, #resetHydratedState, the staged global-key map and
the three per-map getOrCreate helpers are deleted; apply/remove take the
target state. The duplicate-global check now lives only in apply. Name and
index lookup rebuilds build a local map before assigning.
Python: the pull derives names and indexes into local dicts and assigns
values, envelopes, names and indexes together; _rebuild_indexes delegates to
the same pure _derive_lookups.
Rust already stages into a local map and assigns self.entries last, with no
derived lookups; unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A load is all-or-nothing. TypeScript checked for a second record of a
global type inside #applyHydratedEntry, which runs after
#resetHydratedState, so a refused pull left the cache cleared and partly
reloaded. The check now runs in the staging pass beside the home check,
before anything is cleared; the apply step's pull-only branch is gone.
Python already validated before swapping, but its check only fired when
the __global__ record arrived second, so a store holding __global__ then
another key of a global type loaded both. Any second record of a global
type is now refused regardless of order.
Rust has no global document types and replaces its entry map only after
staging; it had neither flaw.
pullWithDuplicateGlobalLeavesCacheUnchanged (TS) and
test_pull_with_duplicate_global_leaves_cache_unchanged (Python) fail on
9ab1706 and pass here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conform cultcache-py to the store-composition contract, following C#:
- add_backing_store and add_generic_store raise, attaching nothing, when
the new store would move the home of any document type the cache
already holds.
- add_backing_store(store, []) attaches the generic store (same as
TypeScript, Rust and C#'s untyped attach) instead of silently doing
nothing.
- pull_all_backing_stores stages every store's records and replaces the
cache's view only when all loaded. A record delivered by a store that is
not its home raises naming the key and both stores; nothing is admitted
and no store is written.
The zero-store in-memory test passes on 6bb41f3 as well (Python already
conformed); the other new tests fail there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conform cultcache-rs to the store-composition contract, following C#:
- add_backing_store returns Err, attaching nothing, when the new store
would move the home of any entry type the cache already holds.
- pull_all_backing_stores loads every store into a staged image and
replaces the cache's entries only when all loaded. An entry delivered by
a store that is not its home returns Err naming the key and both
stores; nothing is admitted and no store is written.
- A cache with zero stores is in memory: put, put_envelope,
put_raw_envelope, put_prepared_batch and delete no longer error. Once
any store is attached, a type with no home still errors.
Tests for each rule fail on 6bb41f3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conform cultcache-ts to the store-composition contract, following C#:
- addBackingStore refuses, attaching nothing, when the new store would
move the home of any record type the cache already holds (for example a
store routed to "settings" attached after settings:app landed in the
generic store).
- pullAllBackingStores reads, decodes and home-checks every record before
replacing the cache's view. A record delivered by a store that is not
its home throws naming the key and both stores; nothing is admitted and
no store is written.
- A cache with zero stores is in memory: put, putEnvelope and delete no
longer throw. Once any store is attached, a type with no home still
throws.
Tests for each rule fail on 6bb41f3.
Co-Authored-By: Claude Opus 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>
Cut 5 (Python). stores_by_type maps each type to one store and generic_stores
becomes a single generic_store. add_backing_store raises on a type already
claimed and add_generic_store on a second generic store; a refused
registration claims nothing.
_stores_for_type and _all_specific_stores are deleted. _store_for_type returns
the home store and raises when stores are attached but none is home to the
type. It returns None only for a cache with no stores, which the contract
keeps as an in-memory cache. put, put_envelope, put_envelopes and delete each
write one store; pull_all_backing_stores pulls each distinct store once.
Tests: test_second_generic_store_rejected, test_type_claimed_twice_rejected,
test_types_route_to_home_store. No bytes change.
Version 0.2.0 -> 0.3.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cut 5 (TypeScript). The cache no longer mirrors: StoreRoute and its mirrors
list are gone, #resolveRoute returns the single home store (the store that
claims the type, else the generic store), and put, putEnvelope and delete write
exactly one store.
addBackingStore now refuses a second generic store and a type already claimed
by another store, attaching nothing when it refuses, matching C#. No bytes
change; nothing in CultLib attached more than one store.
Tests: rejects a second generic backing store, rejects a type registered to
two stores, routes each type to its home store. The writer-by-reader interop
test now runs the C# write-routed mode and checks that catalog.cc and run.cc
are each a complete v1 snapshot holding only their own record, and that every
reader (TS, Rust, C#, Python) reads the routed catalog store.
Version 0.13.5 -> 0.14.0; cultnet-ts and cultmesh-ts depend on ^0.14.0 so the
workspace link still satisfies their range.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PlayerData, CultMeshSimulationFact, the Networking interop peer's
CultNetInteropNote, the cultnet-py benchmark item and the Caching test
documents lacked [MessagePackObject]. Only the generated codec could
serialize them; MessagePackSerializer refuses such types. Non-public test
documents take [MessagePackObject(AllowPrivate = true)], as consumer
internal documents already do, and ConditionalCommitTests.Slow marks its
private backing fields [IgnoreMember].
Same [Key]s, so payloads are unchanged: a pre/post probe over the
Networking assembly and both interop peers showed identical schema id,
content hash and payload bytes for all 9 documents, PlayerData equal once
its Guid.NewGuid() default id is masked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.cc is MessagePack-based, so the core no longer pretends to be
serializer-independent. GameCult.Caching references
MessagePack.Annotations 3.1.7 and reads KeyAttribute and
IgnoreMemberAttribute by type instead of matching their names. The
benchmark's own MessagePack.Annotations reference is dropped; it arrives
through GameCult.Caching.
A generic document's options come from its generic definition's
assembly: Doc<X> uses Doc's declared resolvers, never X's.
GenericDocumentUsesItsOwnAssemblyResolvers compiles GenericHolder<T>
into an assembly whose resolver writes Pair as [B, A] and checks the
payload uses it rather than Pair's own assembly resolver.
Contracts: schema-compatibility states the slot rule and its
rejections; store-composition states the generic resolver rule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The benchmark references only GameCult.Caching, which carries no MessagePack
dependency, so the [Key]s BenchItem gained in ea5a07b did not resolve and the
tool stopped building. It references MessagePack.Annotations, which brings the
attribute without MessagePack's analyzer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The static UntrustedData options are gone. An assembly declares its formatter
resolvers with [assembly: CultCacheFormatterResolver(typeof(R))];
CultDocumentMessagePackSerialization.OptionsFor(assembly) composes them ahead
of CultDocumentResolver and StandardResolver, once per assembly, and hands
assemblies that declare nothing the base Options. Generated codecs and the
reflective SerializeUntyped/DeserializeUntyped path both read
OptionsFor(typeof(X).Assembly). No registration call, no mutable options, no
load order.
CultMessagePackSecurity copies UntrustedData and supplies a collision-resistant
comparer over CultRecordRef<T>.Key.Value, so ref-keyed dictionaries
deserialize.
Tests: RefKeyedDictionaryRoundTrips failed on 825b4f7 ("No hash-resistant
equality comparer available"); DeclaredResolverEncodesValueTypeAsPositionalArray
and UnknownAssemblyGetsBaseOptions did not compile there (new API);
InteropNoteBytesUnchanged is a pin whose payload constant was captured on
825b4f7. BenchItem, the one unkeyed [CultDocument] in any consumer (in-memory
benchmark, no store), gains [Key]s in its existing slot order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README and the Rust README described mirrors as C# behavior; the parity
scope claimed the C# SoA table; the generator README described DatabaseEntry
formatters that no longer exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
enroll_service_identity_at and export_service_identity_trust_anchor
both routed through prepare_parent, which chmods the target directory
to 0700 on unix. That is correct for the private enrollment path but
wrong for exporting a public trust anchor: Idunn's provisioning and
control-plane code export the public anchor into a directory another
service already owns and reads from (Idunn/src/provisioning.rs,
Idunn/src/control_plane.rs), and forcibly narrowing that directory's
permissions on every export would lock out the legitimate reader
instead of protecting anything.
Split the helper: ensure_parent only creates the directory, and the
renamed prepare_private_parent (used solely by enrollment) also locks
it down. Ported from GameCult/cultnet-rs commit 69f2498 ("Preserve
public anchor parent permissions"), which fixed the same bug before
that standalone repo was superseded by this package.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A concurrent reader of the store's destination file (another process
opening it, an indexer, an antivirus scan) makes MoveFileExW fail with a
sharing or access error for as long as it holds the handle. That is
transient by construction; treating the first refusal as the outcome
killed Muninn serve on 2026-09-11 the moment a status CLI overlapped a
write. The replace retries such errors for up to half a second. The test
holds the destination open from another thread and expects the replace to
outlast it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both RUDP socket readers returned None after consuming a packet that carried
no frame or event for the caller: an acknowledgement, a ping, a fragment
that completed nothing yet. A caller that polls once per send therefore read
one incoming datagram per outgoing one, and fell behind its peer's
acknowledgements as soon as a reliable channel shared the session; every
unacknowledged packet was then resent, and the resends starved the sends.
Measured on the media path 2026-09-10: 7 video frames in 9 s with reliable
audio on, 266 without. Readers now consume no-event packets and keep
reading; None means the socket is empty.
The client no longer acknowledges unreliable frames. They carry no sequence,
so the ACK told the sender nothing and cost it one datagram to read for
every one it sent.
The fragment-set bound test now asserts eviction, which is what the bound
has done since the fuse was cut earlier today; the old assertion was hiding
behind a truncated test run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A media stream advertisement now carries `media_endpoint`, where the
producer serves it from; the request no longer carries a consumer endpoint.
The producer listens and the consumer connects, the same way as every other
client/server pair on the mesh, so a viewer's host admits nothing inbound
and needs no firewall rule or port forward. Unshipped contract, keys
renumbered in place.
Audio gets its own channel ("audio": reliable, unordered, media expiry) so
one session can carry lossy video under parity and reliable audio together,
which is what lets the producer serve both to one dialled-in consumer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two state records beside the media wire records, travelling as CultMesh
documents through Odin rather than on the media channel:
- gamecult.media_stream_advertisement.v1: a stream a producer can serve
on request — its video and audio sources, the codecs it can encode,
audio sample rate and channels, defaults, and the connection id it
dials a receiver with. Keyed by stream_id. A consumer's picker is built
from these.
- gamecult.media_stream_request.v1: a consumer asking a producer to start
or stop serving a stream to a receiver endpoint, with sources, codecs,
bitrate and latency budget. Keyed by stream_id:receiver_id so a newer
request supersedes; the producer answers on the same key by rewriting
state and detail.
Until now the only way to pick a stream was Muninn's own
muninn.obs_stream_catalog and muninn.capture_stream_command, which live
in odin-core and bind every consumer to one producer. Odin persists any
well-formed document, so nothing there has to change to carry these.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The builder forced requested_keyframe whenever chunk keys were missing.
A producer answers each new keyframe request with an IDR, so every
repair request would have cost a keyframe — the thing a repair request
exists to avoid. The old C++ receiver sent repairs with the flag off;
this builder had never been driven by a real receiver. A whole missing
frame still implies one, since the repair cache holds chunks.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
build_receiver_feedback normalises the record a receiver sends back —
damage lists sorted and unique, chunk keys well-formed, a keyframe
requested whenever something is missing — and until now lived in Muninn,
the one end that never sends it. Ratatoskr sends it; Muninn's sender
tests construct it to exercise the repair cache. A builder both ends
call belongs beside the record it builds.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The C# reference publishes authorityRoutes with certificate: null for
loopback routes, and its own verifier treats null as "no certificate" in
local development. The TypeScript JSON schema only allowed the object,
so the browser client rejected every catalog the getting-started
provider served: "certificate: must be object", Odin could not resolve
the Verse, and the real browser/C# checkpoint has failed since f622ee1
(2026-08-18), behind the sample failure that hid it. TypeScript
consumers already guard route.certificate; the schema now accepts null.
The package-closure smoke also still spelled the browser identity with
providerId; it is authorityRuntimeId since c194bd4.
npm test --workspace packages/cultnet-ts -> 54 pass
npm run test:pack:ts -> passed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The portability workflow has been red since 2026-08-22. Three things,
each hidden behind the one before it:
- Eve started refusing to build a command intent for a surface that has
not advertised a command boundary and receipt schema (Eve 4338a42,
2026-08-22). The two-runtime sample never advertised one. It now passes
a provider advertisement with worldInteraction for its surface, which is
the declaration the lowering was right to demand.
- The sample and the package-closure script still imported "cultcache-ts";
the package became @gamecult/cultcache-ts on 2026-09-04 (8cb3b72) and
CI never got far enough to say so. Two READMEs carried the same import.
- The job pinned Eve to codex/eve-browser-host-isolation, a branch that was
never merged and stopped moving on 2026-08-25. The sample is written
against Eve main and passes there; the pin now says so.
The interop workflow's Python step ran one test file by path; it now
discovers the three split suites.
scripts/verify-eve-two-runtime-sample.ps1 -EveRoot F:\Projects\Eve
(Eve main ccc3ffe) -> browserCount 1, headlessCount 1, restartCount 1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
setuptools >= 77 rejects "License :: OSI Approved :: MIT License" once a
PEP 639 license expression is set, and `pip install -e` builds with the
newest setuptools. That has failed both CI workflows at the install step
since 2026-09-04. `license = "MIT"` is the expression; the classifier
said the same thing and is gone from all three Python manifests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One distribution named cultcache-py carried three libraries. Rust and
TypeScript layer them as cultcache < cultnet < cultmesh; Python now does
the same, one package per layer, module names unchanged so every
`from cultmesh_py import ...` keeps working.
Two imports ran against that layering and are resolved by moving code to
where the dependency direction says it lives, not by adding a shim:
- cultnet_py.interop_peer imported the Verse/peer catalog, authority
lease, stream, and simulation-fact contracts from cultmesh_py. They are
wire contracts a bare peer has to speak without owning a node, which is
why cultnet-rs and cultnet-ts keep them in the net layer. They now live
in cultnet_py.cultmesh_contracts; cultmesh_py re-exports them.
- cultcache_py.benchmark imported cultnet_py. It measures framed CultNet
parse beside cache paths, so it and compare_csharp move to cultnet_py.
verify checks all three packages and moves to cultmesh_py.
The single 6,693-line test module is split by what each test touches: a
test using any cultmesh_py name lands in cultmesh-py, else cultnet_py in
cultnet-py, else cultcache-py. Each suite runs with only its layer and the
layers below on the path, so a test that reaches up fails to import
rather than quietly binding the packages back together. 11 / 53 / 78 =
the same 142.
Workflows install all three; the publish job is one matrix over the
three packages keyed by tag prefix. The TS test harnesses put all three
src directories on PYTHONPATH. cultcache-py bumps to 0.2.0 because it no
longer contains cultnet_py or cultmesh_py; the new packages start at
0.1.0. Nothing was ever published, so no PyPI consumer moves.
cultcache-py 11 tests, path: cultcache-py/src
cultnet-py 53 tests, path: + cultnet-py/src
cultmesh-py 78 tests, path: + cultmesh-py/src
python -m cultmesh_py.verify -> ok, all five checks
cultnet_py.interop_peer imports with cultmesh_py absent
Not run here: the cultnet-ts interop ring and cultcache-ts store matrix
need dotnet/kotlin peers; their PYTHONPATH change is four lines.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A seventh argument sets max_fragment_bytes on the sender. Without it a
3,392-byte payload leaves loopback as one datagram, and a matrix that
claims to measure fragmentation measures nothing: the impair proxy's
received count equalled the payload count in every cell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A receiver that survives loss by discarding stranded sets has to say so, or
the drop is invisible and the next diagnosis starts from "delivered less
than sent" again. CultNetTransportStats now carries fragment_sets_evicted,
summed across hub peers like reliable_packets_expired, and the media
delivery probe prints it beside delivered_pct.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A fragment set that lost one fragment was never reclaimed: the buffer map
only shrank on successful reassembly or a payload-size violation. Once 64
sets were stranded, every new fragmented payload returned an error and the
receiver's loop terminated. Any nonzero loss on any fragmenting channel was
therefore a countdown, not a threshold: 3 fragments at 3% loss killed the
receiver at ~700 payloads, 1% at ~2,100. Found by the media load test.
When the bound is hit, evict the set untouched longest and count it via
fragment_sets_evicted(). A stranded set's siblings were delivered long ago;
dropping it is the right call and it was already lost.
Also stop saturating the fragment id. saturating_add parked every set after
the 65,535th on one shared id, and the reassembler stitched them together.
That is the July 2026 eight-minute failure; the id now wraps to 1.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cultcache-py removed a fragment buffer only on successful reassembly, the
same shape cultnet-ts had: a set that loses one fragment is kept for the
life of the session and the map grows under any loss on a fragmenting
channel. Cap at 64 to match the other runtimes, evict the oldest stranded
set (dict insertion order) rather than refuse the payload, and expose
fragment_sets_evicted so the drop is observable.
The fragment id already wrapped correctly here; only the Rust allocator
saturated.
PYTHONPATH=src python -m unittest tests.test_cultcache -> 142 run, 1
pre-existing error (interop fixture path is cwd-relative, same on main)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cultnet-ts removed a fragment buffer only on successful reassembly, with
no cap, no eviction and no timeout. A fragment set that loses one
fragment was stranded for the life of the session, so the map grew
without bound under any loss on a fragmenting channel. cultnet-rs at
least fails closed at 64 pending sets; TypeScript just consumes memory.
This is live, not theoretical. Ghostlight sends Heimdall sealed envelopes
over CultMesh RUDP with max_fragment_bytes = 2048
(Ghostlight/crates/ghostlight-dungeon/src/heimdall.rs:697), and Heimdall's
private command plane reassembles them through this path. Every lost
fragment on that link left a buffer nothing would ever collect, on a
long-running auth service.
Caps pending sets at 64 to match cultnet-rs's max_pending_fragment_sets,
and evicts the oldest stranded set rather than refusing the payload:
a receiver that stops accepting fragmented traffic after a bounded number
of losses is a denial of service delivered by the network. Map iteration
is insertion-ordered, so the first key is the oldest. fragmentSetsEvicted
and pendingFragmentSetCount make it observable.
Found by the Muninn media load test, which hit the Rust half of the same
defect; cultcache-py has the same unbounded map and is not touched here.
npm run test --workspace=packages/cultnet-ts -> pass 54, fail 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moving the envelope moved less than it should have. Muninn's decoder validated
every record and checked the envelope's key against the key derived from the
record's own contents; the version that landed here did neither, so the move was
quietly a downgrade. Six of Muninn's tests failed on the switch and said so,
which is the only reason this is a paragraph rather than a bug.
The four record validators and the video chunk key move here with it. They check
the contract, not one producer's use of it: non-empty identifiers, non-zero
timebases, sane pressure figures, well-formed chunk keys. Left in Muninn they
would have protected the producer and left every consumer to invent its own idea
of a well-formed record — which is how a C++ receiver and a Rust sender came to
disagree while each looked correct alone.
The record-key check is the sharper one. A key is derived from the record it
addresses, so a mismatch means envelope and payload disagree about what the
thing is. Admitting that would let a consumer address media by a key its
producer never meant.
VideoChunkKey comes too. Its string form ("42:1") travels in receiver feedback
and is parsed at the other end, so it was already contract living in a producer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both branches are the same body of work seen from two angles: what a media
stream is, and what its channel does. Muninn needs both to build, so they merge
here rather than making a consumer track two refs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A media record travels wrapped in cultnet.document_put_raw.v0 with a schema id,
a record key and provenance. Both ends have to agree on that exactly, and until
now only Muninn had it: the producer was Rust in one repository and the consumer
was 6,515 lines of C++ in another, each with its own reading of the same bytes.
Ratatoskr could not decode a real stream without it.
Two things change in the move rather than after it.
The producer namespace is now the caller's. The Muninn implementation hardcoded
"muninn-media:" into every message id and "muninn.media" into every tag, so a
second producer would have had to label its traffic as Muninn's to be understood.
`MediaWireProvenance` carries it instead, and a test asserts the envelope names
no producer the caller did not supply.
The four hand-written wire payload structs are gone. Each restated a record's
entire field list as a positional tuple, in order, kept in step by discipline
alone — and existed for one reason: `#[serde(with = "serde_bytes")]` on the
payload, which cannot be applied to a DatabaseEntry struct because the derive
does not read serde attributes. Now that the records carry
`#[cultcache(key = N, bytes)]` the derive emits bin itself, so the records
serialize directly.
That last part is a wire claim, so it is tested rather than asserted:
`the_derive_reproduces_the_hand_written_wire_payload_byte_for_byte` reconstructs
the legacy struct and compares output byte for byte. It matches, which makes the
deletion a simplification instead of a silent break.
Record key shapes move here too. A consumer parses them to tell a chunk from its
siblings and a retransmission from a new frame, so they are contract and belong
beside the records rather than in whichever producer wrote them first.
Muninn's copies are not removed yet; that is the other half of this move.
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>
Four of cultnet-rs's ten DatabaseEntry byte fields lack
#[cultcache(key = N, bytes)] and so serialize as MessagePack
array-of-integers rather than bin: IdunnDeploymentBrakeRecord.signature,
IdunnDaemonHealthTrustBindingRecord.signer_public_key,
GameCultServiceTrustAnchorRecord.signer_public_key, and
IdunnAuthenticatedProviderHealthProjectionRecord.signature. Converting
them touches live stores, so the question is what breaks.
Measured rather than reasoned, and the answer is the opposite of what I
expected. Reading is compatible in both directions: serde_bytes::ByteBuf
accepts an array marker, and a plain Vec<u8> accepts bin, so the flip is
neither a hard read break nor a one-way door. No dual-read path is
needed and reverting the binary is survivable.
What does change is reproduction. A record written before the flip can
be read but can never re-encode to the bytes it was stored as, and its
canonical digest moves with it. That is the cost, and it lands on
byte-exact store checks and canonical_sha256, not on readability -- so
the migration is a re-encode question, not a compatibility one.
Also pins the size difference the media work is chasing: 406 bytes as an
array against 278 as bin for a 256-byte payload, 1.46x, independently
matching the 1.45x measured on an 848-byte payload.
cargo test --test byte_slot_migration -> test result: ok. 4 passed; 0 failed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three media payload fields take the derive's `bytes` attribute, so they
serialize as MessagePack `bin` instead of an array of integers. An 848-byte
payload goes from 1234 bytes on the wire to 851 — 1.45x off every media byte,
on the one link measured to be starved.
This is a correction to the previous commit's reasoning, not a new capability.
That commit claimed the derive could not emit bin and left a test ignored
against a hint that needed designing. The hint has existed all along:
cultcache-rs-derive parses `bytes` at lib.rs:92, serializes through
serde_bytes::Bytes at :135, and decodes through ByteBuf at :154 and :170. What
is inert on a DatabaseEntry struct is `#[serde(with = "serde_bytes")]`, because
the derive reads only its own attribute namespace — which is a different and
much smaller claim than the one that was made.
The ignored test is now un-ignored and passing, and the tolerant assertion it
was propping up is exact again.
Four fields elsewhere are missing `bytes` and are deliberately untouched here:
IdunnDeploymentBrakeRecord.signature, IdunnDaemonHealthTrustBindingRecord
.signer_public_key, GameCultServiceTrustAnchorRecord.signer_public_key, and
IdunnAuthenticatedProviderHealthProjectionRecord.signature. Those carry stored
state and signing history — six trust anchors live in the deployment projection
on yggdrasil, and the brake store is byte-exact checked — so they need
sequencing with a re-sign rather than a flag flip. Owned by the Swarm Migration
work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four record types described a media stream and nothing about who produced it,
while living in odin-core under a `muninn.` prefix. Every field is
producer-agnostic; the name was the only coupling, and a second producer would
have had to impersonate Muninn or fork the shape. They move to cultnet-rs as
`gamecult.media_*`, matching the prefix already used for cross-service contracts
like gamecult.runtime_presence_health and gamecult.service_trust_anchor, while
cultmesh.* stays reserved for session primitives.
Field order and key indices are carried over untouched, including the `default`
on missing_video_chunk_keys. The tests pin what a consumer in another language
is entitled to rely on: positional layout, the slot each field occupies, and
that a record written before that field was appended still decodes.
One test is ignored rather than passing. Byte payloads currently serialize as
MessagePack arrays of integers because the derive writes its own Serialize and
ignores serde attributes; C#, TypeScript and Python all emit bin, which makes
Rust the divergent runtime and costs 1.45x on every media byte. Fixing it needs
an opt-in hint in cultcache-rs-derive rather than a global change, because
signatures are taken over the whole positional serialization and canonical
hashes over the same bytes — flipping byte encoding estate-wide would invalidate
the live Idunn authority chain. That work is sequenced by the Swarm Migration
session; the ignored test un-ignores when the hint lands.
Parked on its own branch. The Muninn and Odin ends of this move are not done:
odin-core still declares these types, and muninn-daemon still imports them from
there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dual-proved runtime presence contract existed only in cultnet-rs, so no
TypeScript service could make a statement Odin admits as presence. Odin files
anything else away as an ordinary document and never produces a topology
correlation for it, which is why Heimdall could warm forever without being
promoted.
The port is what it should be: the same fields at the same key slots. The Rust
derive serialises a positional tuple indexed by those slots, so the TypeScript
side names the slots and fills the same 24. Both proofs cover one payload --
the complete record with both signature fields empty -- and the two signing
messages use the domain separation their respective identities already define.
Conformance is against vectors emitted by cultnet-rs, not a fixture written
for both sides at once, which would agree with itself and prove nothing:
`cargo run --example runtime_presence_vector -p cultnet-rs` regenerates them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Odin carried defineOdinDocuments: 45 CultCache document-type definitions for
documents that cross service boundaries. Eve surfaces and provider
advertisements, Ghostlight, Heimdall, Idunn, Muninn, Sleipnir, Aetheria,
VoidBot, Stonks. Almost none of it is Odin. It lived there because Odin was
written first.
That was tolerable while Odin was the only consumer of consequence. It stops
being tolerable when Hermodr moves to its own repository, because the choice
then is a published contract or two definitions of one wire format, and the
second is the failure the Hermodr extraction exists to remove.
The catalog is now defineSwarmDocuments here, beside defineDocumentType, which
is what builds it. defineDocumentType stays injected rather than imported: that
is how it worked in Odin, it keeps the catalog clear of the generic signature in
./document, and it lets a caller supply another runtime.
Ported as a mechanical transform rather than retyped, then verified rather than
assumed: serialising all 45 definitions from both implementations against the
same probe gives byte-identical output and identical key sets. The 43 callbacks
that gained annotations are named DocumentValue rather than bare any, because
these definitions predate typing and read fields defensively, and pretending
otherwise would be inventing types nobody has verified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Odin carried a 324-line module named idunn-rudp.cjs. Thirteen of those lines
mentioned Idunn, and most were error strings. The rest was CultNet client work:
socket binding, RUDP handshake, Ed25519 signing, packet receipt, msgpack
framing. It sat in a consumer because that is where it was written, not because
that is where it belonged, and it was about to be duplicated when Hermodr moves
to its own repository.
The transport now lives here as signed-daemon-health, beside the RUDP session it
builds on. The receiving service supplies a contract naming its connection id,
signed schema, and unsigned diagnostic schema; nothing service-specific is
hardcoded.
The signature and identity domains are deliberately not parameterised. They are
the shared gamecult.provider-health domains, and a caller able to vary them
could silently produce statements no verifier accepts. The signing purpose is
the caller's signed schema id, so a statement signed for one service cannot
verify as another's.
The module also loses the sibling-path assumption it was carrying: it no longer
resolves CultLib by walking up three directories from wherever it happens to
live, because it now lives in CultLib.
Type-checks clean and builds. Verified faithful by Odin's existing signed-health
test, which asserts exact canonical statement fields, the observation timestamp,
and Ed25519 signature verification against the ported implementation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
odin-core only re-exported this contract from Odin's tree while the
sibling health/trust records already live in provider_health_contracts.rs.
Moving it here lets idunn-daemon depend on cultnet-rs directly instead of
odin-core, clearing the way to extract idunn-daemon into its own repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>