Commit graph

833 commits

Author SHA1 Message Date
Meta Vens
1982d73e44 Name the managed document in the parked SoA entry
CultManagedDocument<T> and its two Document<T> openers came in with the SoA
storage (5428870) and are preserved at the same parked/cultcache-soa tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:41:55 +02:00
Meta Vens
88e1c8e157 Delete CultNetDatabase's transaction wrapper
ExecuteTransactionAsync (both overloads), RequireTransactionsForAuthoritativeWrites,
EnsureAuthoritativeTransaction and the AfterCommit deferral had no consumer
outside CultNetDatabase_RequiredTransaction_DefersRawPutPublicationUntilCommit,
deleted with them. Writes publish immediately, as they already did outside a
transaction. The cache's own transaction machinery stays until Cut 3 replaces
it with the explicit batch commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:35:56 +02:00
Meta Vens
d26f357f3e Delete CultManagedDocument and both Document<T> openers
CultManagedDocument<T>, CultCache.Document<T> and CultNetDatabase.Document<T>
had no consumer outside the tests removed with the SoA table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:23:55 +02:00
Meta Vens
fe7e5fbe37 Delete the parked CultCache SoA table
Removes CultSoaTable, CultSoaColumn, CultCacheSoaStore, CultCacheSoaTypeTable,
CultCacheSoaMember, CultCache.Soa<T>, the _soa field and its four hooks, and
the two tests that exercised them (with their TransformEntry and
MeshManagedPlayer fixtures). The last working form is at
parked/cultcache-soa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:22:54 +02:00
Meta Vens
d904960610 Park the CultCache SoA table at parked/cultcache-soa
The SoA table was built for ECS-style structure-of-arrays columns over
cached documents and has no consumer outside tests. It is tagged before
removal; docs/parked-features.md names the tag, span, and restore path, and
the runtime parity scope says the table is parked rather than wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:21:25 +02:00
Meta Vens
0db1fe5448 Apply Soul fixes to the store composition docs and routing tests parked/cultcache-soa
Conditional commit, not plain flush, is what protects against unseen data.
C# batch and conditional commit are specified, not implemented. Home-type
claims use exact equality. TypeScript attach does not read. CultMath has no
encoding. Reflective and generated descriptors differ until Cut 4. Routing
tests now check the inherited Name, the global flag precondition, and that a
refused write publishes no change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:13:20 +02:00
Meta Vens
d81112fe44 Add red store-routing tests
Eight tests against today's API, each red for the behavior the store
composition contract removes: attach pushes into a second store, the generic
parameter chooses schema, exact-type name/global/watch lookups, invented
globals destroying a seeded file, and a refused push leaving the document in
memory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:01:32 +02:00
Meta Vens
486fe560af Correct stale store-routing, SoA, and generator docs
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>
2026-09-13 16:01:32 +02:00
Meta Vens
37f73c4e38 Write the CultCache store composition contract
One home store per document type, attach is hydration, loading never writes,
read-only stores, runtime type decides schema, assignable lookups, globals
never invented, explicit-batch and conditional commit (a plain flush is
last-writer-wins), per-assembly serialization options, and positional-array
value-type encoding. The persistence-format transaction section becomes the
explicit-batch shape; schema compatibility says which schema a record carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:01:32 +02:00
Meta Vens
e9b91c1f50 Preserve public trust-anchor parent permissions parked/cultnet-discovery-contracts
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>
2026-09-13 14:45:40 +02:00
Meta Vens
e9e9c60b0f REC-102: cut stale generated mirrors and a dead maintenance receipt
Deletes four confirmed-dead tracked files:
- CultLib.txt, src/GameCult.Caching/GameCult.Caching.txt,
  src/GameCult.Unity/Assets/UI/UI.txt: concatenated snapshot copies of live
  source, carrying stale E: drive provenance and obsolete dependency
  versions. Live source under src/ remains the owner.
- tests/GameCult.Networking.Tests/lcov.info: tracked coverage output.
  .vscode/tasks.json:14 still generates it fresh via
  "/p:CoverletOutput=./lcov.info" on every coverage run, so tracking the
  stale committed copy in git is the liability, not the generation step.
- .voidbot/status/void-memory-maintenance.json: a one-time "starting"
  receipt (added whole in 4813570, never touched again) naming a dead
  E:\Projects\CultLib path and an unimplemented repo_face_heartbeat
  launcher. VoidBot's actual heartbeat state lives at
  .voidbot/status/repo-face-heartbeats.{cc,json} (see
  packages/config/src/index.ts REPO_FACE_HEARTBEAT_STATE_PATH) — a
  different file entirely; nothing reads this one.

Kill attempts:
- git grep -n for each of "CultLib\.txt", "GameCult\.Caching\.txt",
  "UI\.txt", "lcov\.info" across origin/main, excluding each file's own
  path: only .voidbot birth/distiller snapshots (auto-generated onboarding
  file-listing catalogs, not functional readers) and the one real lcov.info
  producer in .vscode/tasks.json.
- rg across all of F:/Projects (excluding target/node_modules/.git):
  gamecult-site's docs/research/gamecult-org-repo-ledger.json lists
  "CultLib.txt" as a filename in a static historical repo-file inventory
  (not a functional consumer); Aetheria/docs/cultcache-migration-cut.md
  cites GameCult.Caching.txt:157 as an evidence line number in a
  migration-history note (stale citation after this cut, not a functional
  break — flagged for the record, not fixed here since that doc is out of
  scope).
- voidbot MCP search for void-memory-maintenance.json / repo_face_heartbeat
  found only the unrelated repo-face-heartbeats state path, confirming no
  reader.

Collision check: git diff --stat origin/main against each of the other 4
CultLib worktrees (codex/recover-aetheria-authority,
codex/delvehold-isosurface, claude/media-stream-catalog, and the two
detached-HEAD worktrees, both confirmed ancestors of origin/main) over
these five paths: all empty, no in-flight work touches them.

REC-102

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:10:10 +02:00
Meta Vens
c2a9a6e526 Windows atomic replace waits out a transient reader
Some checks failed
Publish packages / @gamecult/cultcache-ts to npm (push) Has been skipped
Publish packages / cultcache-py to PyPI (push) Has been cancelled
Publish packages / cultmesh-py to PyPI (push) Has been cancelled
Publish packages / cultnet-py to PyPI (push) Has been cancelled
cultcache-py-v0.2.0
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>
2026-09-11 02:44:17 +02:00
Meta Vens
47b9fc4ee3 A reader returns "nothing" only when nothing is waiting
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>
2026-09-11 01:21:37 +02:00
Meta Vens
c84cb2e862 The consumer dials the producer
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>
2026-09-10 22:50:20 +02:00
Meta Vens
61b7bf0e24 Say how Odin admits catalog records, and what it refuses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:24:04 +02:00
Meta Vens
780e1a48cd Advertise and request media streams without naming a producer
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>
2026-09-10 22:07:39 +02:00
Meta Vens
4160ab560b A repair request is not a keyframe request
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>
2026-09-10 21:35:04 +02:00
Meta Vens
34c8ea79b9 Build receiver feedback where both ends can see the rules
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>
2026-09-10 21:33:06 +02:00
Meta Vens
89687fceb4 Let the browser sample read the provider's advertisement
Eve refuses to build a command intent for a surface that has not
advertised a command boundary and receipt schema, and the intent it
builds now carries the operation under operation.operationId. The
browser sample did neither: the click was refused inside the page and
the checkpoint saw only a 30 s timeout on the receipt.

The C# provider now publishes its gamecult.eve.provider_advertisement.v1
document with the surface's worldInteraction, and the page leases that
and hands it to the lowering as `provider`. The provider owns the
declaration; the page reads it rather than restating it. The command
sink reads intent.operation.operationId.

The checkpoint now forwards the page's console and pageerror output.
"CultMesh browser operation is required." took one run to find with it
and had been a bare timeout without it.

  scripts/verify-eve-getting-started.ps1 -EveRoot ..\Eve (Eve main)
  -> Eve getting-started verification passed: clean .NET and TypeScript
     artifacts, DOM and headless convergence, real Chromium and C#
     clients, Odin discovery, authority-bound routes, receipts,
     persistence, and route replacement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
f94f5581b0 Let the verse catalog schema say what the reference emits
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>
2026-09-10 13:55:46 +02:00
Meta Vens
672235e8f3 Ignore Python package build output
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:55:46 +02:00
Meta Vens
830a5d718f Make the Eve onboarding checkpoint run against the contract that exists
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>
2026-09-10 13:55:46 +02:00
Meta Vens
6d98b03471 Drop the license classifier setuptools now refuses
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>
2026-09-10 13:55:46 +02:00
Meta Vens
95493862bd Split the Python runtime into cultcache-py, cultnet-py, cultmesh-py
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>
2026-09-10 13:10:47 +02:00
Meta Vens
a8aeddacab Let the media probe fragment on demand
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>
2026-09-10 11:59:48 +02:00
Meta Vens
66f05e161a Expose fragment-set evictions in transport stats
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>
2026-09-10 11:59:48 +02:00
Meta Vens
d3bc6e6604 Keep receiving when fragment sets go stale
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>
2026-09-10 11:59:48 +02:00
Meta Vens
b6b1d9c660 Bound the Python fragment reassembly map
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>
2026-09-10 11:42:17 +02:00
Meta Vens
2041a0e0ed Bound the TypeScript fragment reassembly map
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>
2026-09-10 11:42:13 +02:00
Meta Vens
05e0925143 Merge the media stream contract into CultLib 2026-09-10 02:32:21 +02:00
Meta Vens
96138ffcfa Validate on decode, where both ends inherit it
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>
2026-09-10 02:23:56 +02:00
Meta Vens
a58968e7ac Bring the media delivery choice alongside the contract
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>
2026-09-10 02:07:13 +02:00
Meta Vens
a3e6877945 Move the media wire envelope where both ends can reach it
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>
2026-09-10 02:00:19 +02:00
Meta Vens
4111fbb468 Write down that a stored record's encoding is part of its contract
Three byte-exact re-encode guards exist across three separate Idunn
stores -- the runtime projection, the deployment brake, and the trust
bindings -- each decoding a record, re-encoding it, and refusing on any
byte difference. Idunn's control store does the same for its own
transactions. Three independent instances is a house pattern, and it
means changing how any persisted CultCache record serializes is never a
flag flip.

Records the measured behaviour of #[cultcache(key = N, bytes)] rather
than the assumed one: reading stays compatible both ways, so the cost
lands on reproduction and therefore on those guards, not on
compatibility. A field with no live producer is free; a field with
persisted records needs its store rewritten in the same change.

Also records that Option<Vec<u8>> cannot take the attribute today, and
that a shape-validated field nobody produces may be a fossil rather than
an omission -- the estate has at least one other case of a live consumer
outliving its producer.

This was learned by nearly deploying an attribute flip that would have
made Idunn refuse the projection store Odin reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:18:45 +02:00
Meta Vens
36f31920ce Let a caller choose what the media channel does
`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>
2026-09-10 01:08:31 +02:00
Meta Vens
9e6027e8d8 Measure what adding a bytes slot does to records already written
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>
2026-09-10 00:42:26 +02:00
Meta Vens
2ba404cc0b Carry media payloads as bin
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>
2026-09-10 00:31:11 +02:00
Meta Vens
a250ec69eb Give the media stream records a home that is not one producer's
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>
2026-09-10 00:17:19 +02:00
Meta Vens
d1a9edad3a Port gamecult.runtime_presence_health.v2 to cultnet-ts
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>
2026-09-07 21:42:54 +02:00
Meta Vens
b304df5e47 Promote the swarm document catalog into cultcache-ts
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>
2026-09-06 19:40:37 +02:00
Meta Vens
5407be713f Add generic signed daemon-health publication to cultnet-ts
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>
2026-09-06 19:29:33 +02:00
Meta Vens
c13b6ba07e Move IdunnDaemonHealthTrustBindingRecord into cultnet-rs
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>
2026-09-05 17:39:46 +02:00
Meta Vens
ae0ac0d3c9 Reconcile cultmesh-rs: adopt Odin's lineage, keep CultLib's additions
CultLib's packages/cultmesh-rs and Odin's vendor/cultmesh-rs had diverged
independently -- 87 shared symbols, 6 only here, 9 only there. Neither was
simply ahead of the other, so this is a merge rather than a fast-forward.

Odin's copy is the base because it is the deployed code, running in production
via Idunn, and because it carries a deliberate semantic upgrade: connection_id
was replaced by a per-request message_id correlation with validation requiring
raw callers to supply one. That brings in the RUDP document server (647 lines)
and its tests (553), which the original migration into CultLib left behind.

Layered back in from CultLib's lineage: soa(), get_all_with_keys(),
create_rudp_document_message(), and the plural batch publisher. The batch
function was rewritten off the removed connection_id onto the connect helper.

Both publish paths survive deliberately. They are different jobs, not rival
implementations: the singular path polls one receipt for application-level
acknowledgement and can report Invalidated, while the batch path sends and
then flushes at the transport level. That is also why the census framed the
30s-versus-300ms flush_timeout as a value conflict and was wrong -- one field
name, two mechanisms. The distinction is now documented at the call site.

Surviving connection_id references are the RUDP protocol's own connection
epoch and are unrelated to the removed options field.

cargo test: 16 passed, 0 failed -- 10 lib, 6 document-server integration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:26:44 +02:00
Meta Vens
77313ed48c Stop the interop harness leaking peers and hanging after it passes
The suite finished its work in about 60 seconds and then never exited. Four runs
were left alive for hours, holding 11 node and 2 dotnet peers, and a stranded
peer holding cultnet_interop_peer.exe is what made two unrelated cargo builds
fail with LNK1104. In CI this is a job that burns its whole timeout on green
tests.

Two real defects, found by asking the stuck process what it was waiting on
rather than by guessing. process.getActiveResourcesInfo reported four UDPWraps
and two PipeWraps still held after the last test.

The four UDP sockets: eight tests bind a server socket with bindUdpSocket and
close only the transport wrapper in their finally, never the socket itself. Four
leaked per run, matching the four Python lanes that fail on a machine without
msgpack -- the wrapper is built inside the try, so when the peer's readiness
rejects it never exists to be closed, and the raw socket is orphaned. Now closed
through a guarded helper that tolerates a transport having adopted it.

The teardown registration: t.after was registered after every `await ready`, so
a peer whose readiness rejected skipped the registration entirely and stranded
every peer already spawned. Moved ahead of the spawns at all four sites.

With those fixed the suite still does not drain on its own, so test:interop now
passes --test-force-exit. That is safe only because cleanup demonstrably
completes first: a forced run now leaves zero peers behind, where before it left
nine. Forcing the exit without the fixes above would have guaranteed orphans
rather than prevented them.

Verified: 65 seconds, exit 1, the expected 15 pass and 12 fail, no surviving
processes. The 12 failures are the Python lanes, which cannot run on this
machine and are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:32:10 +02:00
Meta Vens
8cb3b7282b Publish the non-Rust packages and make the licensing uniform
Rust consumers pin a CultLib revision through cargo git dependencies, which is
how Ghostlight, Epiphany, CodexConnector and Odin already consume the core
stack. npm and pip cannot take a package from a subdirectory of a git repo at
all, which is why the TypeScript and Python consumers vendored forks instead:
there was nothing else to depend on. This gives them a versioned artifact.

Tagging cultcache-ts-v* or cultcache-py-v* builds, tests and publishes. The tag
version must match the manifest version or the workflow refuses rather than
shipping a mislabelled artifact. PyPI uses trusted publishing, so no long-lived
token is stored; npm needs an NPM_TOKEN secret.

cultcache-ts is now @gamecult/cultcache-ts. The scope is not cosmetic: it makes
the package unambiguously org-owned on a public registry, and it is what lets
CultLib hold the name rather than racing for an unscoped one. CultLib's own
cultmesh-ts and cultnet-ts and their imports move with it; all three packages
typecheck and cultcache-ts passes 12 of 13 tests, the exception being the
cross-runtime store test that shells out to Python, which cannot run on this
machine because its Python has no msgpack.

Licensing was inconsistent with the repo. cultcache-py declared itself "Private
GameCult infrastructure" and cultcache-rs and cultmesh-rs declared nothing,
inside a repo whose LICENSE is MIT and whose every other package says MIT. All
three now say MIT. That was load-bearing here: a package that calls itself
private is not one to push to a public registry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:41:44 +02:00
Meta Vens
e171eca323 Expose what the consumer forks had to reach for
Surveying the swarm's vendored copies, the divergence is far smaller than the
number of forks suggests. Against CultLib, Epiphany's CultCache holds nothing
unique, CodexConnector's holds nothing, VoidBot's CultCache-TS holds nothing,
and Ghostlight's CultCache-Py holds nothing. What the forks did hold was mostly
reach: things CultLib has but does not expose.

raw_document_record_from_envelope was private here and public in every Rust
consumer fork. It is public now. A first pass called it missing, which was a
visibility difference read as an absence -- a `pub fn` scan cannot tell those
apart.

CultMesh gains get_all_with_keys and soa, thin delegations to CultCache, which
does support both. Those were genuinely absent rather than hidden.

cultmesh-rs also needed the media expiry field on its document transports; it
was not rebuilt when cultnet-rs grew that field because the earlier pass tested
only two of the three Rust packages.

cultcache-rs 53, cultnet-rs 107, cultmesh-rs 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:15:59 +02:00
Meta Vens
cbde940df2 Let the advertised profile own each channel's reliable expiry
The first pass put the media deadline on the transport options and carried it as
a runtime field on the connection and hub. That is a second answer to what a
channel does, able to disagree with the profile the peer was actually shown.

CultNetTransportChannel now carries reliable_expire_after_ms and the send path
resolves the deadline from the profile, so there is one answer. The runtime
fields are deleted rather than left as a shadow copy. The transport option
remains as the knob that populates the profile, not as a parallel authority.

Odin's fork had the field in exactly this place, which is how the gap surfaced:
its muninn test reads it off the channel descriptor.

This is wire-visible, unlike the rest of the expiry work. The field is optional
and skipped when absent, so a profile that does not set it is byte-identical to
one from a runtime that has never heard of it -- but the RUDP profile does set
it on the media channel, so Rust peers now advertise it. Verified against the
interop lane: TypeScript, C# and Kotlin all accept the profile unchanged, in
five passing lanes each. The Python lanes cannot run here because this machine's
Python runtime has no msgpack, and they fail identically before and after this
change.

The other four runtimes should grow the field so a profile round-trips through
them without losing the channel's declared behaviour.

cultnet-rs: 107 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:57:44 +02:00
Meta Vens
8f29ee568d Add the per-entry snapshot compare-exchange Odin consumes
A first pass at Odin's vendored CultCache compared only its uncommitted diff
against CultLib and concluded nothing else was missing. That was wrong: a
whole-file symbol comparison found CultCacheExpectedEnvelope and a per-entry
compare_exchange that CultLib lacks and Odin's crates import. Comparing a diff
tells you what changed recently, not what the fork uniquely holds.

Unlike the snapshot form this constrains only the identities it names, so an
unrelated concurrent write does not lose the race. Expecting `None` makes it a
create-once claim, which is what an admitted-generation owner needs.

The expectation-identity guard is carried over as written even though it is
unreachable: a condition whose envelope names a different identity is already
rejected by the value comparison above it. Left in place rather than quietly
dropped, since it costs nothing and the lookup it depends on may change.

cultcache-rs: 53 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:32:07 +02:00
Meta Vens
bac5be88b9 Restore reliable expiry and snapshot compare-exchange to the Rust core
Two capabilities existed only in Odin's vendored forks, which is why the forks
could not be deleted. Both are ported here with tests so Odin can consume
CultLib instead.

cultnet-rs gains reliable send expiry. A reliable packet may now carry a
deadline; past it the packet is dropped instead of retransmitted, and the count
is reported through session and transport stats. Live media tolerates loss but
not lateness -- a frame retransmitted after its moment has passed is worse than
a missing one -- so the media channel takes a 75ms default while state channels
never expire. Handshake packets never expire either: a dropped connect is not a
late frame.

The deadline is fixed when a send is admitted, so time spent waiting in the
backlog spends it exactly as time spent awaiting an acknowledgement does. This
lineage has a queued_reliable backlog that Odin's does not, and a queued packet
promoted after its moment had passed would be as useless as a retransmitted
one. Expiry runs before the window is measured, so an expiring stream is not
refused for a backlog it no longer owns.

cultcache-rs gains compare_exchange_snapshot and a nonblocking
try_compare_exchange_snapshot. CultLib had neither, despite carrying the larger
body -- the two lineages had diverged in both directions. Contention is returned
as data rather than waited on, so the caller keeps retry and scheduling
authority. This is the primitive an admitted-generation CAS needs.

No wire format changes. Expiry is a sender-side policy and compare-exchange is
local storage, so the other four runtimes stay in agreement without matching
these refusals. They are still worth carrying eventually, and the parity ledger
already tracks that this kind of gap does not announce itself.

cultnet-rs: 106 tests. cultcache-rs: 51 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 03:21:01 +02:00
Meta Vens
85f7024a35 Refuse a framed length beyond the advertised maximum
Found while checking what would be lost by deleting Ghostlight's vendored fork.
Its copy of transport.rs carried a guard CultLib never had: tcp_framed receive
compared the declared payload length against the max_payload_bytes the profile
advertises for the schema channel, and refused before allocating.

Without it the length in a four-byte header is taken on trust and used directly
to size a buffer, so one header reserves whatever the peer names before a single
payload byte arrives. The guard sits ahead of the allocation, which is the only
place it does any good.

It is inert unless a profile advertises a maximum, so the test advertises one.

This is the last thing unique to that fork. Everything else in it is now here or
was already here, which is what makes the fork safe to delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:09:11 +02:00