Commit graph

1,014 commits

Author SHA1 Message Date
Meta Vens
a35c46afff Fix the interop lanes' dotnet-test success assertion to match real output
Verified live on Windows: a passed NUnit test under `dotnet test --logger
"console;verbosity=detailed"` prints "Test Run Successful." and "Passed: N",
never the literal "Passed!" the assertion was checking for. Both the native
and managed connector lanes now match the real strings; both were confirmed
to pass for real against a live TypeScript provider (lane 1: 137ms, lane 2:
145ms with the golden-bytes hex line matching the TypeScript peer's own
encoding byte for byte).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:18:37 +02:00
Meta Vens
d526286d60 Add Cut 5's four QUIC realtime provider interop lanes
TypeScript provider vs C# native connector (the Unity path, gated to
Windows since the native connector under test is); TypeScript provider vs
C# managed connector, with a golden-bytes check comparing the C# test's
re-encoded frame against the TypeScript peer's own encoding of the same
frame; latest-only under a stalled consumer (two dials against one 200-frame
provider, one paused mid-stream via the peer script's new --pause-after),
asserting the provider's broadcasts stay bounded regardless of the pause and
both consumers converge on sequence 200.

Both dotnet-test lanes run `dotnet test --filter FullyQualifiedName~<test>
--logger "console;verbosity=detailed"`: detailed verbosity is what surfaces
a passed test's own TestContext.Out lines, which the golden-bytes lane reads
back out of stdout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:10:44 +02:00
Meta Vens
fa8a9f4e31 Let the TS interop peer prove convergence and golden bytes for lane 3/4
dial gains --expect-sequence (stop once a frame with that sequence arrives,
rather than counting frames received — the number of frames a stalled
latest-only reader sees is not the interesting value, what it converges on
is) and --pause-after/--pause-ms, which simulate the stalled-consumer test
hook at the only level this subprocess can reach: not calling receiveFrame
for a while. The shared native pump keeps dispatching and coalescing into
this transport's own inbox regardless of whether the app reads, so not
reading is externally equivalent to not polling.

serve now also logs each broadcast frame's own TypeScript encoding as hex, so
the harness can compare it against the same frame's C#-side re-encoding
without importing the codec itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:09:37 +02:00
Meta Vens
962162e7c6 Rename and add the C# sides of Cut 5's external-provider lane
NativeConnectorReceivesFromExternalManagedProvider -> NativeConnectorReceivesFromExternalProvider:
CULTMESH_NATIVE_EXTERNAL_ENDPOINT's peer is no longer necessarily the C#
managed provider now that Cut 5 adds a TypeScript one on the same lane; the
env var name stays, since it names the endpoint, not the peer.

Add ManagedConnectorReceivesFromExternalProvider, gated by the same env var:
dials an external provider with the plain managed connector and no
ValidateProviderCertificate (so acceptance runs on the endpoint's own
cert-sha256 pin alone, like an ordinary consumer), and prints the received
frame's CultMeshRealtimeWireProtocol re-encoding as hex for the harness's
golden-bytes comparison against the TypeScript peer's own encoding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:06:15 +02:00
Meta Vens
25d749067e Add a serve mode to the TS QUIC interop peer for the provider lane
cultmesh-quic-peer.ts serve opens a CultMeshQuicRealtimeProvider on the same
test fixture credential the unit tests use, prints {status, endpoint} in the
shape spawnServeProcess already expects, waits for a first peer before
broadcasting (a provider's broadcast to zero peers silently sends nothing,
same reasoning as the C# quic-realtime-serve peer's
WaitForFirstConnectionAsync), then broadcasts --frames frames at --delivery
with --interval-ms between them, stopping early on stdin EOF like the C#
peer's WatchStdinCloseAsync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:06:05 +02:00
Meta Vens
3f546fc5cd Add unit tests for the QUIC realtime provider
Reliable-ordered ordering in both directions, latest-only coalescing (a
synchronous back-to-back publish is deterministically coalesced, not by
network timing luck), a stalled peer's outbox holding at most one pending
frame per key across 200 broadcasts, a non-blocking broadcast, the uppercase
cert-sha256 pin, unreliable failing closed, and eviction on peer shutdown.
Runs against the real native bridge and Cut 4's real consumer connector, no
mocks; skips when CULTMESH_QUIC_NATIVE_DIR is unset, matching every other
QUIC test file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:05:56 +02:00
Meta Vens
e938cb9e59 Add the TypeScript QUIC realtime provider and its coalescing outbox
CultMeshQuicRealtimeProvider (packages/cultmesh-ts/src/realtime-quic.ts) is
the StreamPixels role: listen() opens a native listener, computes an
uppercase cert-sha256 advertisedEndpoint (the Unity connector requires the
pin), and accepts peers into a Set of the same CultMeshQuicRealtimeTransport
Cut 4 already built for consumers. broadcast() mirrors
CultMeshQuicRealtimeServer.BroadcastAsync: reliable-ordered awaits every
peer's send_complete via transport.sendFrame directly (Task.WhenAll,
evict-on-failure); latest-only publishes into a new per-peer
CultMeshQuicRealtimeProviderOutbox and returns without waiting on any peer's
I/O. The outbox pumps sendFrame's existing fresh-stream-per-frame path one
frame at a time, so a stalled peer holds at most one pending frame per
(channel, body) key.

Extracted CultMeshRealtimeLatestOnlyCoalescer from the inbound path's
inline Map+FIFO bookkeeping (unchanged behaviour) so the new outbox reuses
the same coalescing idiom instead of a second inline copy.

Accepting a connection buffers native events behind a synchronous listener
registration (the pump dispatches strictly sequentially, so registering
before any await is what makes the async runtime-reference acquisition that
follows safe) and replays them once the peer's CultMeshQuicRealtimeTransport
exists; a handshake timer evicts a peer that never completes CONNECTED.

Wired into the CultMesh facade as createQuicRealtimeProvider.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:05:47 +02:00
Meta Vens
8e32bff135 Add a provenance note for the QUIC test fixtures
The PKCS12/DER pair had no record of how it was made or what it is. Documents
the CN, the openssl recipe that reproduces an equivalent pair, and that
nothing depends on today's exact bytes past what the tests read back out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:46:25 +02:00
Meta Vens
72da8862b3 Fix the spawnServeProcess stdio type after making stdin optional
A non-literal first stdio element loses spawn()'s typed-tuple overload
entirely (verified on Yggdrasil: tsc failed on the two other, unrelated
serve helpers' stdout/stderr going nullable), not just for stdin's varying
element. Asserted at the one call site instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:42:18 +02:00
Meta Vens
e4e81d1628 Install libmsquic system-wide in the QUIC dev image so .NET can find it
Finding 8(c): the dev image builds the Node bridge's own libmsquic copy into
a build-local directory (scripts/build-quic-native.sh), which is never on the
loader's search path. .NET's System.Net.Quic resolves "libmsquic" through
that path independently and failed with PlatformNotSupportedException before
this. The smallest honest fix: dpkg -i the same pinned 2.5.9 deb (same URL,
same SHA-256 as the build script) system-wide and run ldconfig, so both the
Node bridge's own copy and .NET's independent resolution find a libmsquic.

Changes the image; the new tag is cultlib-quic-native-dev:75cb89e73850
(first 12 hex of this file's sha256), built fresh on Yggdrasil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:39:05 +02:00
Meta Vens
499315afc5 Keep stdin piped for the QUIC interop peer, and close it at teardown
Finding 8(a): every spawnServeProcess call used stdio ["ignore", ...], which
hands the C# quic-realtime-serve peer an already-closed stdin. Its own
WatchStdinCloseAsync (Program.cs:1261-1269) treats that as EOF and tears the
serve loop down at once, before it can send anything. spawnServeProcess gains
an opt-in `stdin: "pipe"` (default stays "ignore" for every other serve mode,
which never reads stdin), used only by the "CultMesh QUIC realtime" test; its
teardown ends that pipe explicitly before killing the process.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:38:58 +02:00
Meta Vens
fbbc6dcfc1 Wait for a client before quic-realtime-serve broadcasts
Finding 8(b): the serve mode broadcast immediately after printing its ready
line, before the harness's dial could reach it, so frames went to an empty
peer set and were silently dropped. WaitForFirstConnectionAsync polls
ConnectionCount, bounded at 30s so a peer that never gets dialed still exits
instead of hanging the interop lane forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:38:51 +02:00
Meta Vens
047c55c453 Add tests pinning fixes 1-6, M2, M3, M6-M9, and dedupe the two nativeBridgeAvailable helpers
Each rule below fails under its own mutation of the code fixed in the previous
two commits; verified by hand and restored, per the fix-batch brief, not
committed as a mutation harness (the operator retired that pattern).

- realtime-quic-native.test.ts: release() refuses a call with no outstanding
  reference (fix 2).
- realtime-quic-consumer.test.ts: a stream-kind/delivery mismatch faults only
  its connection while a second, independent connection through the same
  pump keeps working (fix 1 / M4); a peer-initiated shutdown rejects a
  pending receive and cleans up exactly once, so a further dispose is a
  no-op and a fresh receive rejects instead of hanging (fix 3); a slow
  latest-only reader accumulates at most one pending frame per key, and an
  equal generation is dropped outright (fix 6, M10); a real connected
  transport's isVerifiedFor rejects a generation other than the one it
  connected with, and the session manager disposes a real (not stubbed)
  transport whose generation no longer matches a freshly verified route
  (M3); the advertised certificate pin matches case-insensitively (M2).
- realtime-quic-session-manager.test.ts (new): eviction of a dead cached
  session and redial on the next connect, single-flight sharing one dial
  across concurrent callers, refusing a transport that lands after dispose()
  (all fix 4); the race returning on first success without waiting out a
  slow candidate (fix 5); the maxRacedCandidates cap (M6); the protocolIds
  and authorityRuntimeId route filters (M7, M8); the session key including
  authorityRuntimeId (M9). Trust verification is skipped via
  mode: "local-development" over loopback endpoints, since these tests are
  about the session manager's own binding/racing/caching rules, not route
  trust (already pinned by realtime-quic-consumer.test.ts's trust
  negatives).

Subtraction: `nativeBridgeAvailable` was defined twice, disagreeing on
whether to check the dependency library too; both test files now import one
definition from test/support/native-bridge.ts (the stricter of the two).

Gap, reported rather than papered over: the stream-shutdown pruning half of
fix 6 (streamKinds) has no direct test, because the map is private and
nothing here exposes it; the committed test only shows a second frame still
arrives after the pruned stream's shutdown, which does not by itself prove
the entry was removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:38:46 +02:00
Meta Vens
04858d332a Fix the QUIC consumer's peer-shutdown leak, and give the session manager eviction, single-flight and first-success racing
Soul's fix batch, findings 3, 4, 5 and 6:

- A peer-initiated CONNECTION_SHUTDOWN only set `disposed = true`, so
  `dispose()`'s early-return skipped `release()` and `offConnectionEvent`: the
  runtime reference leaked forever and a pending `receiveFrame` hung forever.
  `disposed` and "already cleaned up" are now the same flag, run through one
  `cleanup()` reached from `dispose()`, a peer shutdown, and the new `fault()`
  (wired from fix 1's `onFault`). Every path releases the runtime reference
  exactly once and rejects every pending and future receive/send with the real
  reason instead of a generic "disposed" message.
- `CultMeshQuicRealtimeSessionManager.connect()` cached a transport forever,
  even a dead one, and raced candidates with `Promise.allSettled`, so one dead
  route delayed every connect by the full handshake timeout. It now: evicts a
  session once its transport reports itself disposed (`onDisposed`, mirroring
  `CultMeshSessions.InvalidateRealtimeSession`); shares one in-flight dial
  across concurrent callers for the same target (mirrors C#'s `Lazy`); refuses
  a transport that lands after `dispose()` instead of caching it; and races
  each tier for the first verified success (mirrors `Task.WhenAny`), aborting
  and disposing the losers via an `AbortSignal` threaded into
  `connector.connect`, which now also honours a signal already aborted at
  entry.
- `streamKinds` never pruned a finished stream (`STREAM_SHUTDOWN` fell into
  `default`); it now removes the entry. The receive side queued every
  latest-only frame in an unbounded array; it now mirrors the C# reference's
  `CultMeshRealtimeInbox`: at most one pending frame per `(channel, body)`,
  coalesced on arrival, while reliable-ordered frames still queue directly
  (unbounded, matching the C# reference's own unbounded channel).

Subtraction: `trimmedOrEmpty` duplicated `cultnet-ts`'s exported `trimCSharp`;
callers now use the shared one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:38:34 +02:00
Meta Vens
245a9fdf52 Isolate the QUIC native pump from a faulting connection, fix release()'s UAF ordering, and make 64-bit koffi decodes honest
Soul's fix batch, findings 1, 2 and 7:

- The pump's dispatch had no fault boundary: a malformed frame, a stream-kind
  mismatch, or a throwing caller callback (certificate validator, waiter) threw
  out of `dispatch` and killed the pump, taking the whole process with it.
  `dispatch` now catches per-connection, shuts that connection down at the
  native level, removes its listener, and hands the error to an optional
  `onFault` callback registered alongside the listener. Every other connection,
  and the pump itself, keep running.
- `release()` called `runtimeClose` before awaiting the pump's exit, so a
  queued-but-not-yet-started `nextEvent.async` call could begin after close and
  run against freed memory. It now marks closed, awaits the pump loop (it wakes
  within its 250 ms timeout), and only then closes. It also refuses a release
  with no outstanding reference instead of silently flooring at zero.
- koffi decodes a 64-bit `uint64_t`/`void *` as a plain `number` when it fits a
  safe integer, not `bigint` (probed on the pinned 3.3.0 build against the real
  bridge). Every declared type here promises `bigint`; `toBigInt64` is now the
  single boundary conversion, applied to every 64-bit decode, so the mismatch
  that made `event.code === BigInt(CULTMESH_QUIC_SEND_CANCELED)` never true is
  fixed at its source instead of patched at that one comparison.

Subtraction: deleted the unused `lastStatus` export (wired but never called),
and collapsed the stacked eslint-disable-next-line pair over one line into one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:38:22 +02:00
Meta Vens
c57b613582 Move QuicRealtimeServeConfig to the file's type-declaration tail
Program.cs is a top-level-statements file: every type declaration (the
existing ServeConfig, DialConfig and friends) lives after every top-level
statement and local function, all clustered at the end. The new record landed
between two local functions instead, which is CS8803: "top-level statements
must precede namespace and type declarations." Moved beside DialConfig, where
its neighbors already live.

Found by a direct `dotnet build` run on Yggdrasil after the interop test's own
error message was truncated by Node's console printing before reaching the
actual compiler diagnostic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:18:20 +02:00
Meta Vens
b4ddf49cc7 Run .NET globalization-invariant in the QUIC dev image
The interop lane's dotnet build FailFasts on startup in this minimal Debian
image: no ICU package, and .NET's globalization stack aborts without one
rather than degrading. The interop peer already parses every argument through
CultureInfo.InvariantCulture, so invariant mode costs it nothing; it is also
Microsoft's own documented fix for a minimal container, cheaper than pinning
and installing an ICU package by name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:14:44 +02:00
Meta Vens
26c026a8c6 Fix the runtime leak that hung the previous ygg-verify job
Self killed a verify run after it sat idle for 25+ minutes past its last
printed result and root-caused it: SIGUSR2 diagnostics on the stuck process
showed an empty JS stack, every libuv handle unreferenced, and MsQuic's own
worker threads parked in epoll_wait — a native runtime that was opened but
never closed, so its registration and threads never tore down and the process
never exited on its own.

The cause was mundane and compounds badly: `RawQuicListener.open()` in
realtime-quic-consumer.test.ts opened a native runtime, then read a test
fixture whose path was wrong (see below) and threw — with no try/catch, so
the runtime it had just opened was never released. That test-file pattern ran
on seven separate tests in one process, each leaking its own reference to the
one process-wide shared runtime; by the time the file's tests finished, the
refcount had never reached zero and the live registration held the process
open indefinitely. `RawQuicListener.open()` now releases the runtime on any
setup failure before rethrowing.

Two more real bugs the same run's fixture-path failures exposed:

- Both new test files resolved fixtures at `join(__dirname, "fixtures", ...)`.
  `__dirname` at runtime is `dist-test/test`, and binary fixtures are never
  compiled or copied there — only `.ts` sources under `test/` and `src/` are.
  Both now read fixtures from their real location, two directories up.
- The negative-grep test looked for a literal `.async(` call. The bugfix in
  the previous commit references `nextEventFn.async` as a value handed to
  `promisify`, never written with a trailing `(` at the reference site, so the
  grep found nothing to check and failed by construction. It now matches
  `.async` as a property reference, not only a call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:13:24 +02:00
Meta Vens
7b8b948d1d Rebuild the koffi out-parameter marshalling on probed behaviour, not a guess
A `node -e` probe against the real bridge (loaded through koffi 3.3.0 on
Yggdrasil) showed the `_Out_` prototype marker does not auto-allocate storage
or reshape the return value: `openFn(null, null)` returned the plain scalar
`-1`, because the null "out" pointer is itself a bad call under the header's
own contract, and koffi never populated anything because there was nothing to
write into. `openFn(null, koffi.alloc("void *", 1))` returns the plain
`int32_t` status and the pointer decodes correctly with
`koffi.decode(buf, "void *")`, as a `bigint`.

Every out parameter is now `koffi.alloc(type, 1)` before the call and
`koffi.decode` after, inside `loadBindings`; the rest of the file is
unaffected, since it already treated ids as `bigint`. The runtime handle gets
its own `CultMeshQuicNativeHandle` alias (`bigint`) so its type is named
rather than smuggled through as `unknown`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:43:35 +02:00
Meta Vens
148bd69c06 Give the raw promisified nextEvent its own 6-argument type
The wrapper cast the raw promisified koffi call to the same 4-argument public
NativeBindings#nextEventAsync type it was implementing, then called it with
six arguments (the two _Out_ placeholders included) — a type checked itself
into a contradiction tsc caught on a clean build. The raw call gets its own
type matching what koffi actually expects; the public wrapper still exposes
four.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:39:52 +02:00
Meta Vens
92dc3a74d6 Fix three real bugs the second ygg-verify pass found
- koffi's `_Out_` sugar still counts toward the call's declared arity in this
  version (an arity check rejected the shorter call); pass `null` at each such
  slot, since the value comes back through the named field on the result
  object regardless of what was passed.
- The negative-grep test read `dist-test/src/...`, which does not exist:
  `__dirname` at runtime is `dist-test/test`, and only compiled output is
  mirrored there, so the original source is two levels up in `src/`, not one.
- Two generated Odin test roots shared one hardcoded key id
  ("odin-root-1"), so trust negative (c) had the trust policy's root looked
  up successfully under the signer's own id and failed on signature mismatch
  instead of "not trusted" — proving the wrong rule. Each generated root now
  gets a distinct key id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:38:53 +02:00
Meta Vens
a44505fab8 Fix the koffi type friction found by the first ygg-verify pass
koffi's own .d.ts resolves as an ECMAScript module under Node16 resolution,
which a CommonJS `typeof import("koffi")` reference and a `koffi.promisify`
property access (which does not exist) both choke on. Type the loaded module
as `any` and promisify `nextEvent.async` with `node:util` directly instead of
guessing at a koffi-native promise helper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:37:10 +02:00
Meta Vens
67d49b567d Give the Linux QUIC dev image Node 24 and the .NET 10 SDK
Cut 4's own npm tests and the C#-to-TS interop lane need to run in the same
container that builds the native bridge, and the image had neither runtime.
Smallest change: install Node 24 from NodeSource and the .NET 10 SDK from
dotnet-install.sh, both pinned by channel/major version the way the rest of
this image pins its toolchain by digest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:12 +02:00
Meta Vens
7e08c5cfd2 Interop: C# managed QUIC provider serving a TypeScript native consumer
tests/GameCult.Networking.InteropPeer gains mode quic-realtime-serve
(--port, --frames, --delivery, --interval-ms): creates a self-signed
certificate the same way CultMeshQuicRealtimeTransportTests.cs does, starts
CultMeshQuicRealtimeServer, and prints one ready line carrying the advertised
cultmesh-state+quic:// endpoint with its cert-sha256 pin, then broadcasts N
frames with increasing sequence, exiting after the loop or on stdin close.
GameCult.Networking.InteropPeer.csproj references GameCult.Mesh.Quic.

Discrepancy, kept: the cut map's worked line for the ready signal is
`{ endpoint: "..." }` alone; this repo's spawnServeProcess harness
(cultnet-interop.test.ts:2491) waits for a line whose parsed `status` is
"ready", the same convention every other serve mode here already uses. The
printed line is `{ status: "ready", endpoint: "..." }` — the map's shape plus
the field its own named mechanism requires — rather than inventing a second
readiness convention for one peer.

packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts: the TypeScript half,
`dial --endpoint --expect N`, connecting with CultMeshQuicRealtimeConnector
and printing the received frames as one JSON line (fields plus payload hex).
It reaches the connector through this package's own src/, never importing
cultmesh-ts itself, keeping the dependency direction the map states
(cultmesh-ts depends on cultnet-ts, never the reverse).

packages/cultnet-ts/test/interop/cultnet-interop.test.ts gains
"CultMesh QUIC realtime: C# managed provider and TypeScript consumer",
spawning the C# peer with spawnServeProcess and the TypeScript peer with
runJsonCommand, both established patterns in this file. Skips when
CULTMESH_QUIC_NATIVE_DIR is unset, the same convention the unit tests use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
Meta Vens
39a8a5f45e Test the Node QUIC binding, consumer and the five trust negatives
packages/cultmesh-ts/test/realtime-quic-native.test.ts: the FFI smoke against
CultMeshQuicNativeRuntime (open/close, a listener with a real PKCS12
credential, a full loopback connection delivering one raw frame end to end),
plus the negative grep that .async( appears only on nextEvent and no
koffi.register exists.

packages/cultmesh-ts/test/realtime-quic-consumer.test.ts: the TypeScript
consumer against a raw listener opened through the native runtime (connect
with the correct pin; refuse a wrong pin; refuse a missing pin with no
validator; a caller validator observes the target and the DER bytes; a late
older generation is dropped), mirroring
CultMeshNativeQuicRealtimeTransportTests.cs and
CultMeshQuicRealtimeTransportTests.cs. Then the five trust negatives through
CultMeshQuicRealtimeSessionManager with a static lookup source and routes
signed with node:crypto (P-256, IEEE P1363 via dsaEncoding): (a) expired
certificate, (b) unsigned remote route, (c) untrusted Odin root, (d) a route
that verifies but pins the wrong TLS certificate, refused by the connector
before isVerifiedFor is ever asked, and (e) a stale/replayed generation,
proven with a stub connector whose transport fails isVerifiedFor, asserting
the session manager disposes it and rejects the connect. There is no
session_open nonce on the QUIC plane (route generation plus the TLS handshake
is its freshness), so (e) is this shape rather than a literal nonce replay,
as the cut map records.

test/fixtures/quic-test.p12 and quic-test-cert.der: a self-signed ECDSA P-256
PKCS12 test credential (OpenSSL, empty password, 1088 bytes — the same shape
as the native bridge's own embedded test PKCS12) and its DER certificate, used
to open real raw listeners and compute the expected cert-sha256 pin at test
time rather than hand-transcribing it.

Discrepancy kept, reported: the C# reference's receive-side inbox also
coalesces a backlog of not-yet-drained latest-only frames of the same key to
just the newest; this transport applies only the generation-drop filter the
cut map names (":538-560") and queues every frame that passes it. The interop
lane's frames are far enough apart that this never matters in practice, but a
consumer that fell behind would see more history here than the C# reference
delivers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:32:45 +02:00
Meta Vens
6e5ba63904 Add the Node QUIC realtime consumer, connector and session manager
packages/cultmesh-ts/src/realtime-quic.ts adds, over the native binding:

- parseQuicRealtimeEndpoint, mirroring
  CultMeshQuicRealtimeTransportConnector.TryParseEndpoint (:98-109).
- CultMeshQuicRealtimeTransport: sendFrame/receiveFrame/dispose/isVerifiedFor,
  the stream-kind-vs-delivery consistency check (:519-524) and the
  per-(channel,body) generation filter that drops older (producerEpoch,
  sequence) (:538-560), both pinned against CultMeshQuicRealtimeTransport.cs.
  Reliable sends reuse one persistent outbound stream behind a gate;
  latest-only opens a fresh stream per send; unreliable fails closed. The
  bridge owns wire framing both ways, so this file never length-prefixes
  anything by hand.
- CultMeshQuicRealtimeConnector: the certificate decision on event 3 (a
  caller validator, else SHA-256-of-DER against the endpoint's cert-sha256,
  else reject), mirroring MatchesAdvertisedCertificatePin (:111-119).
- CultMeshQuicRealtimeSessionManager and CultMeshStaticRealtimeLookupSource:
  binds candidates from authorityRoutes carrying cultmesh.realtime_state.v1,
  verifies each with cultnet-ts's verifyAuthorityRoute before any connector is
  asked, orders by connector then route priority, races at most
  maxRacedCandidates per tier, requires isVerifiedFor after connect and
  disposes a transport that fails it — the realtime path of
  CultMeshSessions.cs's ConnectRealtimeAsync (:833-896), including its
  session key verseId + "\u001f" + authorityRuntimeId (:46).

No Odin WebSocket rendezvous lives here (Q7, ruled A): only the
ICultMeshRealtimeLookupSource port and a static source.

index.ts re-exports the module and gains three CultMesh statics beside the
RUDP ones: parseQuicRealtimeEndpoint, createQuicRealtimeConnector,
createQuicRealtimeSessionManager.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:32:27 +02:00
Meta Vens
c5e49290c7 Bind the CultMesh QUIC native ABI v2 from Node with koffi
Adds packages/cultmesh-ts/src/realtime-quic-native.ts: a lazy koffi binding
over the twelve v2 queue exports plus cultmesh_quic_last_status
(native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h). koffi is
required only inside loadBindings(), so a consumer that never touches QUIC
loads no native module. CultMeshQuicNativeRuntime is a process-wide,
reference-counted singleton running one event pump (a loop awaiting
nextEvent.async with a 250ms timeout, no koffi callbacks) that dispatches
decoded events to registered listener/connection handlers by id.

Also: add the koffi 3.3.0 dependency to cultmesh-ts's package.json, and switch
its tsconfig from classic Node resolution to Node16 so `cultnet-ts/contracts`
(a package.json `exports` subpath) actually resolves — a gap Cut 1 recorded
and left for this cut ("until Cut 4 changes it").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:32:08 +02:00
Meta Vens
cc71f17b7b Say what the cell-mass test proves, and what it does not
Soul: a constant erf passes it. It is one necessary property, not correctness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:43:52 +02:00
Meta Vens
f3aae704e9 Put msquic.dll where the QUIC native test build's loader needs it (Windows)
Running QUIC Cut 3's Windows leg exposed the same class of defect Linux
had at 5533c34: CULTMESH_QUIC_BUILD_TESTS has never had its own packaging
step on Windows, and build-quic-native.ps1's copy of msquic.dll only
lands in the final packaged output directory, never beside the test
binary in the CMake build tree.

Unlike the Linux failure, this one did not fail loudly. gamecult_mesh_quic_native.dll
imports msquic.dll by name, and Windows DLL search falls through to PATH
when the importing binary's own directory doesn't have it. This
workstation has an unrelated msquic.dll on PATH from the Windows
Performance Toolkit (529,480 bytes, unversioned) that is not the pinned
Microsoft.Native.Quic.MsQuic.OpenSSL 2.5.9 build (4,181,856 bytes). Every
asserts-build scenario ran clean against that wrong binary before this
fix, with no error to say so.

Mirrors the Linux POST_BUILD fix: CMake already receives MSQUIC_ROOT, so
the test target now copies msquic.dll from MSQUIC_ROOT/bin/x64 beside
itself instead of leaving the loader to guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:43:21 +02:00
Meta Vens
6e8362b7dc List erf/erfinv in the README's math vocabulary
They shipped without ever being added to the public surface list.
Scalar-only is noted explicitly: every other scalar in math.cs has
float2/3/4 overloads, erf/erfinv do not, and that stays a separate
scope decision since the only consumer (Aetheria fire control) is
scalar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:30:07 +02:00
Meta Vens
4cdc02b200 Give erf/erfinv real accuracy, domain and monotonicity tests
The round-trip test (ErfinvInvertsErf) was the only defense of
erfinv's accuracy, and it is blind by construction: it can only prove
erf and erfinv agree with each other, not that either is correct. A
hand mutation of a single tail coefficient degraded erfinv's worst
error 300-400x (5.0e-7 to 2.0e-4) and every committed test still
passed. Added ErfinvMatchesReferenceValues, pinned against reference
values from bisecting the platform's double-precision erf (Python's
math.erf, glibc-backed) rather than this package's own erf, out to
|y| = 0.9999, at the same 2e-6 absolute bar erf's own reference test
carries. Expected values are computed for the actual float32-rounded
input, not the decimal literal, because erfinv's derivative diverges
near +-1 and that rounding alone moves the true answer by more than
the error bar if ignored.

Verified against three hand mutations, each restored byte-exactly
afterward:
- erfinv tail leading coefficient 2.83297682f -> 2.83317682f: killed
  (4 assertions failed).
- erfinv tail second coefficient 1.00167406f -> 1.00187406f: killed
  (4 assertions failed).
- erf's `p` coefficient 0.3275911f -> 0.3275930f: survives. Soul
  measured this one 2.3x worse but still under the 2e-6 bar the spec
  pins; this is not a fixture gap, it is the mutant staying inside the
  accuracy envelope the tests are supposed to allow. Recorded here
  rather than tightening the bar past what the spec asked for.

Added ErfinvAtDomainEdgesAndBeyond, pinning the domain and edge
behavior from the matching source comment: erfinv(1) = +infinity,
erfinv(-1) = -infinity, and NaN outside [-1, 1] including +-infinity
and NaN input.

Deleted ErfinvIsMonotonicallyIncreasing: it asserted strict
monotonicity, which erfinv does not have. Scanning every representable
float32 in [0, 0.999999] finds 9662 backward steps, first at
y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) --
invisible only because the old fixture stepped by 0.001. erf has the
same shape: 100 backward steps over [0, 4.5], worst drop 1.192093e-7.
Replaced it with two tests: ErfinvIsMonotonicWithinFloatingPointTolerance,
pinning monotonicity to within 1e-6 (comfortably above the measured
noise floor), and ErfCellMassesStayNonNegativeAcrossRealisticGeometries,
the consumer-level property that actually matters for fire control:
differences of adjacent erf values (cell probability mass) stay
non-negative across 16/64/256/1024/4096 cells over +-4 sigma, plus a
tight 1 sigma spread and a very tight 0.25 sigma spread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:30:07 +02:00
Meta Vens
fc9ce970b8 Fix erfinv's sign inversion at the domain edges
math.erfinv(1.0f) returned -infinity and erfinv(-1.0f) returned
+infinity: at value = +-1, w = -log((1-value)(1+value)) = -log(0) =
+infinity, the tail branch's leading coefficient is negative, and
`return p * value` delivered the inverted infinity. erf saturates to
exactly 1.0f at 5.543 sigma, so any interval edge beyond that reaches
this bug in ordinary use.

Guard both edges explicitly before the polynomial runs: erfinv(1) is
now +infinity, erfinv(-1) is -infinity, and anything strictly outside
[-1, 1] (including +-infinity) is NaN, which is the correct answer and
was already the fallthrough behavior. Documented the domain and the
edge behavior in a comment, matching the house style `sign` already
uses at math.cs:84. The approximation itself is unchanged.

Also recorded the true measured accuracy beside erf and erfinv: worst
absolute error 6.621e-7 and 5.066e-7 respectively, both inside the
2e-6 bar this package's tests pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:30:07 +02:00
Meta Vens
5533c34783 Make the QUIC native test build put libmsquic.so.2 where the loader needs it
The README's Linux test build has never worked: both the bridge and the
test executable carry INSTALL_RPATH "$ORIGIN" and expect libmsquic.so.2
beside them, but only scripts/build-quic-native.sh's packaged output
directory ever got that copy. A test build produced a binary that died
at once with "cannot open shared object file". Fix it where the
knowledge already lives instead of adding a README workaround: CMake
already receives MSQUIC_LIB_DIR, so the test target now copies
libmsquic.so.2 beside itself in a POST_BUILD step.

While verifying the README's own worked example end to end, found a
second, independent break in the same docker block: MSQUIC_INCLUDE_DIR
and MSQUIC_LIB_DIR were passed as paths relative to the repository
root, but CMake resolves a relative cache-variable path against the
CMakeLists.txt that consumes it, not the invocation directory, so the
compile failed on a missing msquic.h before it ever reached the
loader. Made both paths absolute with $(pwd) and documented why.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:15:45 +02:00
Meta Vens
1bf581d68b Record the QUIC Linux leg green, and a README path that never worked
All ten scenarios pass under asserts and all four required under release, on
Yggdrasil, in the committed debian:13 image. Worst overshoot anywhere was 4 ms
against a 400 ms tolerance. F1 is closed on Linux: the build that ships now
runs the timeout rule.

Running the README's own worked example is what found the second thing. It
cannot work: the test binary carries an $ORIGIN rpath and nothing puts
libmsquic beside it for a test build. That example was written in the same
commit that deleted the harness, so the working path was removed and the
broken one published together.

Two things stay open. Windows still owes its release configuration for the
same reason Linux did. And F5's stress wants sixteen CPU burners on the
platform where burners are now forbidden, which is the operator's call rather
than mine to route around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:12:05 +02:00
Meta Vens
cbff486b1e Record where Soul's selection probes live, and that losing them is acceptable
The pass ran against 52082ec. Its nine hand probes, including the Rust
both-hops probe that proved S-3 on a matched pair, sit unpushed on soul-probe
in a scratch clone. Probes are throwaway by doctrine, so the pointer says both
where they are and that the findings recorded in this map are the durable
copy. Fix batch 4's tests have to reproduce the behaviour independently
anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:00:23 +02:00
Meta Vens
c5c4c073b4 Correct the record: QUIC batch 8 landed before the retirement
My previous commit rewrote the batch-8 rulings as if the batch were still
owed. It was not: it landed at 5646ff7 and the harness went at 796433f, both
on hands/cultmath-erf. The map was behind the code, not the other way round.

What that changes: the release configuration and the seven mutation entries
landed inside the harness and left with it two commits later, so the release
build is owed again as a plain build. The scenario runner and every seam
survived untouched and now run as direct arguments to the binary.

Cut 3's only outstanding work is the Linux leg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:55:37 +02:00
Meta Vens
7889515993 Rewrite QUIC batch 8 under the harness retirement
Four of the six rulings for the eighth fix batch named harness entries. The
operator retired the hand-written mutation harness the same day, with no
fallback, so those clauses are dead text. What each ruling protected is not:
the release configuration, the seam wrapping the whole duration expression,
pollhammer against a runtime holding live objects, the zero and negative
timeout bounds, and closerace surviving starvation all stand as behaviour.

The bridge is C++ and no ecosystem mutation tool is wired here, so it gets no
committed mutation evidence at all. Its rules are pinned by scenarios at the
layer that decides them; a mutant is something Soul tries by hand and throws
away.

F5's 16 burners now run on Yggdrasil. Those burners are what froze the
operator's workstation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:54:00 +02:00
Meta Vens
c244003339 Record the Stryker triage: a dozen live survivors on this cut's own rules
The triage landed on the fifth attempt. Most of the 85 survivors are message
strings; about a dozen are real, and they sit on the rules the cut exists to
enforce: the alias-numeric refusal degrades to "only if every schema agrees",
a half-blank cites.target stops being refused, the index-alias role naming is
untested at all four sites, the row tiebreak and EdgesFor's ordering are
pinned by nothing, and ComputeDigest's hop branches have no coverage at all.
The equivalent mutants are named so nobody chases them twice.

R-AA's triage half is done; what it now owes is a behavioural test per live
survivor.

Soul also withdrew its own claim that C#'s cursor digest does not sort its
lists. It does, at CultNetSelectionEvaluator.cs:637.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:52:20 +02:00
Meta Vens
a9bb08f9db Record Soul's corrections: the job was not wedged, and the Stryker triage is owed
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:49:08 +02:00
Meta Vens
618b7b562b Record the whole-cut Soul pass: the hop is keyed wrong and the runtimes disagree
Cut 1 may not merge. Row identity becomes (schemaId, recordKey), the hop
behaves identically in both runtimes, the error message obeys its own schema,
and cultnet-rs becomes mutable by cargo-mutants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:44:40 +02:00
Meta Vens
b688cb2091 Cut 12.0: test erf and erfinv behaviour
Covers the pinned accuracy bar against tabulated erf values (0, +-0.5,
+-1, +-2, +-3, within 2e-6), odd symmetry, the +-1 limits, erfinv's
round trip through erf over y in [-0.999, 0.999], and erfinv monotonicity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:42:57 +02:00
Meta Vens
87b95aefa5 Cut 12.0: add erf and erfinv to CultMath
Fire control's Gaussian scatter needs erf for a cell's probability mass
and erfinv to draw the lateral offset, both called per shot. Abramowitz-
Stegun 7.1.26 for erf (stated max abs error 1.5e-7), Giles 2010 (GPU
Computing Gems) for erfinv. Neither is an HLSL intrinsic, so there is no
shader-parity obligation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:42:57 +02:00
Meta Vens
1bcc19298f Record C# pass B, and correct Self's cursor-parity premise
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:23:38 +02:00
Meta Vens
f1aa105472 Record R-N in both runtimes, and the MessagePack key-order trap
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:02:49 +02:00
Meta Vens
806c05a700 Record fix batch 3's landed pieces and what is still pending
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:37:35 +02:00
Meta Vens
796433f2d4 Delete the mutation harness; keep the QUIC scenarios runnable directly
Better to have nothing than a harness that punishes refactoring
(operator ruling, 2026-09-22). Remove scripts/mutate-cultmesh.mjs
(1451 lines) and its Node dependency from the Linux QUIC dev image.

The behavioural coverage stays: native/GameCult.Mesh.Quic.Native's
scenario runner and its seams (waitseam, pollhammer, holdtimeout,
zerotimeout, closerace, and the rest) are untouched. The README and
the dev Dockerfile now document building with
-DCULTMESH_QUIC_BUILD_TESTS=ON -DCULTMESH_QUIC_DEBUG_ASSERTS=ON and
running each scenario as a direct argument to the built binary,
instead of through the harness.

docs/cultnet-selection-cut.md and docs/typescript-quic-realtime-cut.md
still name the harness; they are Self's campaign history and stay as
written.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:41:28 +02:00
Meta Vens
d0480a7d83 Record the operator's acceptance: Q-BP2 A, the title rule, R-D, the Q-J door
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:55:04 +02:00
Meta Vens
5646ff7351 Land the QUIC Cut 3 eighth fix batch: release config, whole-duration seam, pollhammer live objects, zero-timeout bound, closerace starvation fix
Self's ruling of 2026-09-22 named six findings against Soul's eighth pass.
This lands the code side of all six:

- The wait-record seam (CULTMESH_QUIC_DEBUG_RECORD_WAIT) now wraps the whole
  duration handed to `wait_for`, not the raw `timeout_ms` before it is
  converted to milliseconds, so arithmetic applied after that conversion can
  no longer live outside what the seam records. Its "unset" sentinel moves to
  a separate `debug_wait_recorded` flag so a recorded -1 could never be
  confused with nothing recorded.
- `pollhammer` also runs against a runtime holding a live connection to an
  unroutable address (RFC 5737 TEST-NET-1), a best-effort stream on it, and a
  real (never-connected) listener, closing the gap where the wait's predicate
  was only ever exercised on an empty runtime.
- A new `zerotimeout` scenario pins that `timeout_ms <= 0` returns within
  50 ms, probing 0, -1 and INT32_MIN.
- `closerace`'s settle no longer sleeps a guessed duration: where the
  development seam exists it waits for `debug_peak_calls` to reach the poller
  count (reset before any poller thread exists, so nothing can zero a count a
  poller already raised) before a short fixed margin closes the last gap
  between being counted and being inside the condition wait.
- `kGenerousLateToleranceMs` narrows from 800 ms to 400 ms so the wall-clock
  scenarios can catch arithmetic applied outside the seam's own argument,
  which `waitseam`'s equality check structurally cannot see.

scripts/mutate-cultmesh.mjs gains a plain `release` configuration on both
platforms (CULTMESH_QUIC_DEBUG_ASSERTS=OFF, CMAKE_BUILD_TYPE=Release) running
`polltimeout`, `pollbusy`, `pollhammer` and `zerotimeout`, and seven new
mutation entries: R1 (the release macro divides the duration by 10), W1-W3
(600 ms added, a 500 ms floor, and a 1000/909 scale, each applied outside the
seam's own argument), P1 (the idle predicate also ends on a live connection),
and Z1-Z2 (a 500 ms sleep and a 300 ms spin on a non-positive timeout). Two
stale comments are corrected: the scenario list above `assertScenarios` now
names `pollhammer` among the clock-sensitive entries, and a historical "1300"
probe value that no longer matches the test file is removed. The header's
section 7 intro now says the seam serves both the quiesce and the wait
recording, not quiesce alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:27:56 +02:00
Meta Vens
a1309c638c Record the Rust alias port: row-for-row with the reference
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:21:08 +02:00
Meta Vens
8ef170b847 Record the early Soul pass's new findings and rule fix batch 3
Refusals become wire messages, cursors are keyed, one id reaches the
authorizer, and v0 lowering preserves v0's pre-cut answers exactly,
correcting the earlier ruling that lowered an empty list to no filter.

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