From bf9f6f7874c8b3e37c141bb1f538856dba7cb4c2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 13:16:56 +0200 Subject: [PATCH] Map the TypeScript QUIC realtime plane migration Seven cuts against main at f2cd2eb4, with the largest liability priced first rather than discovered later. The native binding was the open question: a probe loaded the committed msquic.dll through koffi under Node 24, drove a handshake on MsQuic's own threads and closed cleanly, with no addon build and no toolchain at load time. It is one cut, not the campaign. The consumer audit found no package at zero external consumers and every external consumer on a file: path rather than the registry, which is why registry naming is an operator question rather than an assumption. Two corrections against the target are recorded in the map rather than absorbed silently: a cited transport filename that does not exist under that name, and a registry path the target assumes for cultmesh-ts that has never existed. Co-Authored-By: Claude Opus 5 --- docs/typescript-quic-realtime-cut.md | 1065 ++++++++++++++++++++++++++ 1 file changed, 1065 insertions(+) create mode 100644 docs/typescript-quic-realtime-cut.md diff --git a/docs/typescript-quic-realtime-cut.md b/docs/typescript-quic-realtime-cut.md new file mode 100644 index 00000000..d2416191 --- /dev/null +++ b/docs/typescript-quic-realtime-cut.md @@ -0,0 +1,1065 @@ +# TypeScript QUIC Realtime Plane Cut Map + +Date: 2026-09-16 + +Status: Imagination output for the target in +`docs/typescript-quic-realtime-target.md` (operator-accepted 2026-09-16). No +code in this document has been written. Nothing here is committed by +Imagination; the root agent commits. + +Anchor: repo `F:\Projects\CultLib`, branch `main`, HEAD +`f2cd2eb4641269c653977bdba9b4bce2a26d870c`. Every `file:line` below is read +against that commit. If HEAD moves, re-anchor before cutting. + +How to read this document: section 1 prices the largest liability first, as +the target requires, because if that price had turned out to dominate the +campaign, the cut order below would have been wrong. Section 2 is the probe +ledger; every mechanism claim in this map carries `(probe)` when it was +established by running something, or `(source read)` when it was established +by reading a file at a named line. Nothing is claimed from a package name or a +README. Section 3 is the consumer audit. Section 4 names rejected options and +why. Section 5 is the authority map for every ownership change. Sections 6-12 +are the cuts. Section 13 holds the operator questions; section 14 the +subtraction estimate; section 15 what could not be assigned. + +Two corrections to the target, recorded here rather than silently absorbed: + +- The target cites `src/GameCult.Mesh.Quic/CultMeshRealtimeTransports.cs`. + That file is `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` + (674 lines). `CultMeshRealtimeTransports.cs` exists, but in + `src/GameCult.Mesh/`, and holds the transport-neutral contracts + (`CultMeshRealtimeDelivery` at :8-13, `CultMeshRealtimeFrame` at :19-44, + `ICultMeshRealtimeTransport` at :46-52, + `ICultMeshRealtimeTransportConnector` at :55-63) `(source read)`. +- The target says the binding is "shipped through a registry". Today only + `@gamecult/cultcache-ts` has a registry publish job + (`.github/workflows/publish-packages.yml:20-24`, `:40-76`); `cultnet-ts`, + `cultmesh-ts` and `cultmesh-browser` have none, and every external consumer + reaches them by `file:` path (section 3). Registry delivery of the binding + therefore also means registry delivery of `cultnet-ts` and `cultmesh-ts`, + which is a naming decision the operator has not yet made. See Q5. + +## 1. The largest liability, priced first + +The target's biggest new surface is CultLib owning native MsQuic bindings for +Node on two platforms. The price depends on three things: what Node itself +offers, what the repo already owns, and how a native artifact reaches a +consumer host without a toolchain. Each was probed. + +### 1.1 What Node offers + +- Node `v24.15.0` on Starfire has no `node:quic`, with or without + `--experimental-quic` (`ERR_UNKNOWN_BUILTIN_MODULE`), and its internal + binding table has no `quic` entry (`No such binding: quic` under + `--expose-internals`). `process.versions` reports `openssl 3.5.5`, no + `ngtcp2`, no `nghttp3`, `napi 10` `(probe)`. Yggdrasil runs Node `24.14.1` + from NodeSource (`F:\Projects\gamecult-ops\inventory.md:660`) `(source read)`, + so the same absence holds on the deploy host. +- Conclusion: there is no first-party QUIC in this Node line. Anything that + works is native. + +### 1.2 What the repo already owns + +- `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp` (440 lines) is a + CultLib-owned C ABI over MsQuic: `cultmesh_quic_open/state/poll/error/close` + (`:309-440`). It negotiates ALPN `cultmesh-state-v1` (`:32`, `:341-344`), + uses deferred certificate validation and answers the pin decision through + `ConnectionCertificateValidationComplete` (`:239-263`), reassembles the + 1-byte-kind plus 4-byte-LE-length QUIC stream framing (`:123-157`), and + queues whole encoded frames for a polling host (`:400-418`). It is Windows + desktop only by construction: ``, ``, `` + (`:1-3`), `CryptHashCertificate2` for the pin (`:184-200`), + `__declspec` exports (`:18-22`), and the CMake build refuses non-Windows + (`CMakeLists.txt:4-6`) `(source read)`. +- The bridge is consumed by the Unity connector through `DllImport` of library + name `gamecult_mesh_quic_native` + (`src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs:259-277`), + which polls every 2 ms by default (`:14`, `:167-203`) `(source read)`. The + built DLL and `msquic.dll` 2.5.9 are committed at + `unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/` (last rebuilt in + `a0813c6`, 2026-09-14) `(probe: git log)`. +- The Windows build is scripted and pinned: `scripts/build-quic-native.ps1` + downloads `Microsoft.Native.Quic.MsQuic.Schannel` `2.5.9` by URL, checks its + SHA-256 (`:13-14`, `:27-41`), and builds with CMake `-A x64` (`:48-52`). + The last build on this machine used Visual Studio 17 2022 Build Tools, MSVC + 14.44 (`artifacts/quic-native-build/x64/Release/CMakeCache.txt`) `(probe)`. + `cl.exe` is not on PATH but Build Tools are installed, so the existing + script works here as-is. +- `tests/GameCult.Mesh.Quic.Native.Tests/GameCult.Mesh.Quic.Native.Tests.csproj:20-29` + builds that bridge before the test build and copies it beside the test + output `(source read)`. `scripts/build-unity-package.ps1:80-82`, `:143-147` + builds it again for the Unity package `(source read)`. + +Conclusion: CultLib already owns the MsQuic body for one platform, one role +(client), and one host (Unity). The cost of the target is extending that body +to a second role (provider), a second platform (Linux x64), and a second host +(Node), not creating a body from nothing. + +### 1.3 How Node loads it + +The decisive probe: from Node 24.15.0, with `koffi@3.3.0` installed into the +scratchpad, `koffi.load` loaded the committed `msquic.dll` then +`gamecult_mesh_quic_native.dll`, bound the five v1 exports by C prototype, +called `cultmesh_quic_open("127.0.0.1", 9, <64 zero hex>)` which returned `0` +with a non-null handle, observed `cultmesh_quic_state` move from `Connecting` +to `Failed` after 34 ms, read the bridge's structured error string through +`cultmesh_quic_error` ("shut down by the transport (status=0xFFFFFFFF800704D0, +error=0x1, msquic=F:\...\msquic.dll, events=event=1)"), got `-2` from +`cultmesh_quic_poll` on a failed client, and closed cleanly `(probe: +scratchpad/koffi-probe/probe.js)`. MsQuic ran its worker threads inside the +Node process and the poll model crossed the boundary without any callback +into JS. + +What that proves and what it does not: + +- Proves: the existing CultLib C ABI is directly loadable and drivable from + the Node line in question with no addon build, no Node headers, no MSVC at + install time, and no per-Node-version artifact. The `.async` call form + exists on bound functions (`typeof state.async === "function"`), which is + the mechanism for a blocking wait off the JS thread `(probe)`. +- Does not prove: a QUIC handshake succeeded (port 9 was intentionally dead); + that the provider role works (the bridge has none); that Linux works (the + bridge is Windows-only). Those are cut deliverables, not probe results. + +koffi facts used by the design, from its shipped documentation, not from +memory: JS callbacks always run on the main thread and calls from other +threads are queued to it, with an explicit deadlock warning if the main thread +blocks on a secondary thread (`node_modules/koffi/doc/callbacks.md:166-176`); +asynchronous calls run on a worker pool sized to the core count with a default +cap of 256 queued calls (`doc/misc.md:26`, `:37`); prebuilt binaries ship for +Windows x64 and Linux/glibc x64 among others, Node >= 16, no compiler needed +(`doc/index.md:17-38`) `(source read)`. Package weight: 1,713,562 bytes +unpacked, 87 files, MIT `(probe: npm view)`. The design below uses no JS +callbacks at all; the only crossing is one blocking `next_event` wait per +runtime issued through `.async`, so the deadlock class in `callbacks.md:176` +cannot occur. + +### 1.4 How it reaches Linux + +- `libmsquic` is published by Microsoft for Debian 13 (trixie) at + `packages.microsoft.com/debian/13/prod`, versions including `2.5.9` + (`pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb`, 2,906,420 bytes, + SHA-256 `1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba`, + `Depends: libssl3t64, libnuma1, libxdp1, libnl-route-3-200`) `(probe)`. + Yggdrasil is Debian 13 (`gamecult-ops/inventory.md:356`) `(source read)`. + The same `2.5.9` pinned for Windows exists for the deploy host. +- The NuGet packages carry Windows binaries only: `Microsoft.Native.Quic.MsQuic.OpenSSL` + 2.5.9 contains `bin/{x64,x86,arm64}/msquic.dll`, `lib/*/msquic.lib`, + and headers `msquic.h`, `msquicp.h`, `msquic_winuser.h`; no `.so`, no + `msquic_posix.h` `(probe: nupkg listing)`. The Linux build needs + `msquic.h`, `msquic_posix.h`, `quic_sal_stub.h` from the msquic source + tree at tag `v2.5.9`; all three are fetchable at + `raw.githubusercontent.com/microsoft/msquic/v2.5.9/src/inc/` (HTTP 200, + 79,280 / 17,909 / 4,731 bytes) `(probe)`. GitHub releases `v2.5.9` carry only + `*_test.zip` bundles; `v2.5.11`, `v2.6.1` carry no assets `(probe)`, so the + deb is the only distribution-grade Linux runtime for this version. +- Build host for Linux: Docker Desktop on Starfire runs a `linux/amd64` engine + `(probe: docker version)`, so a `debian:13` container is a Linux build host + for the development loop. The release artifact must come from a Linux host + the deploy path trusts: Yggdrasil itself, or a CI runner. Q6. +- Windows toolchain: MSVC Build Tools 2022 present; CMake 4.3.2; also a MinGW + gcc from StrawberryPerl on PATH `(probe)`. The map keeps MSVC for Windows + because the committed Unity DLL is built with MSVC `/Brepro` for + reproducibility (`CMakeLists.txt:17-19`) `(source read)`; mixing toolchains + for one DLL is a liability with no capability behind it. + +### 1.5 Price verdict + +The binding does not dominate the campaign. The C body exists; the Node load +path is proven with one 1.7 MB MIT dependency and zero addon tooling; the +Linux runtime is a pinned distro package on the exact target OS; the Windows +build is already scripted. The real cost is the C++ rewrite from a +client-only, Windows-only, per-client poll bridge to a runtime-neutral, +two-role event bridge (section 8), estimated at roughly 1,000 lines of C++ +replacing 440, plus a Linux build script and CI job. That is one cut, not the +campaign. + +What would have changed this verdict: if the bridge had needed JS callbacks +from MsQuic worker threads (it does not; the wait/event model avoids them), or +if `libmsquic` had not been available for Debian 13 at the pinned version (it +is), or if the FFI load had failed on Node 24 (it did not). + +## 2. Probe ledger + +| # | Claim | Method | Evidence | +| --- | --- | --- | --- | +| P1 | Node 24.15.0 has no `node:quic` and no internal quic binding | probe | `require('node:quic')` -> `ERR_UNKNOWN_BUILTIN_MODULE` with and without `--experimental-quic`; `--expose-internals` -> `No such binding: quic`; `process.versions.ngtcp2 === undefined` | +| P2 | The committed CultLib MsQuic bridge loads and runs from Node 24 via koffi | probe | `scratchpad/koffi-probe/probe.js`: open rc 0, state 2 after 34 ms, structured error string, poll -2, clean close | +| P3 | koffi ships Windows x64 and Linux glibc x64 prebuilds, needs no compiler, Node >= 16 | source read | `koffi/doc/index.md:17-38` | +| P4 | koffi async calls run on a worker pool; JS callbacks only on the main thread | source read | `koffi/doc/misc.md:26,37`; `koffi/doc/callbacks.md:166-176` | +| P5 | koffi is 1.7 MB unpacked, MIT | probe | `npm view koffi dist.unpackedSize dist.fileCount`; `package.json:27` | +| P6 | MSVC Build Tools 2022 (14.44) and CMake 4.3.2 are present; the last bridge build used them | probe | `artifacts/quic-native-build/x64/Release/CMakeCache.txt`; `cmake --version` | +| P7 | `libmsquic 2.5.9` exists for Debian 13 amd64 with its SHA-256 and dependency set | probe | `packages.microsoft.com/debian/13/prod/dists/trixie/main/binary-amd64/Packages` | +| P8 | Yggdrasil is Debian 13 with Node 24.14.1 from NodeSource | source read | `gamecult-ops/inventory.md:356`, `:660` | +| P9 | MsQuic NuGet OpenSSL 2.5.9 carries no Linux binary or posix header | probe | nupkg listing | +| P10 | MsQuic POSIX headers are fetchable at tag v2.5.9 | probe | HTTP 200 for `msquic.h`, `msquic_posix.h`, `quic_sal_stub.h`, `msquic_winuser.h` | +| P11 | The pinned `msquic.h` has `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`, deferred/indicated certificate flags, `QUIC_SEND_FLAG_FIN`, `QUIC_STREAM_EVENT_SEND_COMPLETE`, API version 2 | source read | `artifacts/dependencies/msquic-schannel-2.5.9/package/build/native/include/msquic.h:123,132-133,142,246,394-398,1290,1530,1850` | +| P12 | Docker Desktop on Starfire runs a linux/amd64 engine | probe | `docker version --format '{{.Server.Os}}/{{.Server.Arch}}'` -> `linux/amd64` | +| P13 | `openssl` 3.5.5 CLI is available on Starfire (mingw) | probe | `which openssl` | +| P14 | npm names `cultmesh-ts`, `cultnet-ts`, `cultmesh-browser`, `@gamecult/cultmesh-ts`, `@gamecult/cultnet-ts`, `@gamecult/cultmesh-quic-native` are all unclaimed | probe | `npm view version` returned nothing for each | +| P15 | Only `@gamecult/cultcache-ts` and the three Python packages have publish jobs | source read | `.github/workflows/publish-packages.yml:20-24,40-76` | +| P16 | Every external TS consumer uses `file:` paths; none uses a registry version | probe (Explore agent over `F:\Projects`) | section 3 | +| P17 | The StreamPixels deploy path extracts a CultLib tarball beside the app and runs `pnpm install --frozen-lockfile` and `pnpm build` on Yggdrasil | source read | `gamecult-ops/scripts/deploy-streampixels-preview.sh:21,95-103` | +| P18 | The Unity native connector refuses any endpoint without a 64-hex `cert-sha256` pin | source read | `CultMeshNativeQuicRealtimeTransport.cs:103-105` | +| P19 | C# `IsProtected` accepts `wss`, `https`, or any scheme containing `quic`; the browser verifier accepts only `wss` | source read | `CultMeshAuthorityProof.cs:156-162`; `cultmesh-browser/src/index.ts:965` | +| P20 | C# and browser canonical route transcripts are field-for-field identical | source read | `CultMeshAuthorityProof.cs:275-292` vs `cultmesh-browser/src/index.ts:979-996` | + +## 3. Consumer audit + +Method: every `package.json` under `F:\Projects` (excluding `node_modules`, +`dist`, `dist-test`, `.git`, `bin`, `obj`, `Library`, `Temp`, and CultLib +itself and its worktree copies) checked for the four package names, plus a +grep of every `*.ts/*.tsx/*.mts/*.cts/*.js/*.mjs/*.cjs` import or require of +them, with symbols read from the import lines. Vendored copies inside +`Heimdall/vendor/CultLib`, `VoidBot/vendor/cultcache-ts`, and +`gamecult-ops/.artifacts/*` were classified as library-internal, not +consumers. + +Result: + +| Package | Repos | Source files | Symbols actually imported (union) | +| --- | ---: | ---: | --- | +| `cultnet-ts` | 11 (StreamPixels, Heimdall, AetheriaEve, AetheriaEve-cultlib-admission, VoidBot, Stonks, Vili, weksa, Sai, EvePlugins, Bifrost) | 28 | `CultNetRudpSession`, `decodeRudpPacket`, `encodeRudpPacket`, `encodeCultNetMessageForWire`, `parseCultNetMessage`, `invokeCultNetOperation`, `startCultNetOperationServer`, `defineCultNetDocumentBinding`, `CultNetDocumentRegistry`, runtime-presence helpers, and message/packet types | +| `cultnet-ts/contracts` | 0 | 0 | one esbuild alias only (`AetheriaEve-cultlib-admission/scripts/verify-aetheria-browser-provider.mjs:48`); `cultmesh-browser` is its only importer and is inside CultLib | +| `cultmesh-ts` | 7 (StreamPixels, Heimdall, AetheriaEve and its worktree, Stonks, weksa, Odin and its worktree) | 10 | `CultMesh`, `cultMeshRectFromBounds`, `cultMeshViewportRequest`, and types (`CultMeshRudpEndpoint`, `CultMeshDocumentCatalog`, `CultMeshViewportRequest`, diagnostics types) | +| `@gamecult/cultcache-ts` | 1 (Heimdall) | 5 | `SingleFileMessagePackBackingStore`, `defineDocumentType`, `CultCacheEnvelope` | +| `cultcache-ts` (old unscoped name) | 8 | 16 | `CultCache`, `SingleFileMessagePackBackingStore`, `defineDocumentRegistry`, `defineDocumentType`, `/inspection` | +| `cultmesh-browser` | 1 (AetheriaEve and its worktree) | 1 | `CultMeshBrowserClient`, `CultMeshBrowserOdinRendezvous`, `decodeCultNetPayload` | + +StreamPixels specifically: `apps/service/package.json:25-27` declares +`cultcache-ts`, `cultmesh-ts`, `cultnet-ts` as `file:../../../CultLib/packages/`; +`apps/service/src/verse-state.ts:4-6` imports `defineDocumentType`, +`CultMesh`, `defineCultNetDocumentBinding`; `apps/service/src/idunn-rudp-health.ts:2-7` +imports `encodeCultNetMessageForWire`, `encodeRudpPacket`, and two types. +Nothing else in StreamPixels imports these packages. No TS/JS file anywhere +under `F:\Projects` references `cultmesh-state+quic` or `CultMeshRealtime`. + +Sizing consequences: + +- Nothing this migration adds has a consumer yet. The QUIC surface is new + API; no existing export is reshaped, so no external repo changes are + required by the CultLib cuts. StreamPixels adoption is consumer work after + release (target, "Not in this migration"). +- The `verifyAuthorityRoute` family in `cultmesh-browser` is module-private + (not exported at `cultmesh-browser/src/index.ts:955`, `:979`, `:998`, + `:1012`, `:1028`), so moving it (Q2) changes no external import. The three + symbols AetheriaEve imports stay. +- `cultnet-ts/contracts` has no external consumer; adding a sibling subpath + export for the shared verifier follows an existing, internally consumed + pattern rather than inventing a new one. +- The `cultcache-ts` naming split (8 repos on the old unscoped name, Heimdall + on `@gamecult/`) is pre-existing and not touched here, but it is the + precedent Q5 has to reckon with. + +## 4. Rejected options + +- **Node built-in QUIC.** Absent in Node 24 (P1). Not a choice. +- **`@matrixai/quic` 2.0.9.** A Rust/quiche-based QUIC stack with its own + stream, certificate and event semantics `(probe: npm view description)`. + Rejected because the target requires the bytes on the wire, the ALPN, the + stream-kind byte, the deferred certificate pin decision, and the close/abort + codes to match `GameCult.Mesh.Quic` and the Unity MsQuic connector; two QUIC + stacks would have to be proven equivalent at every one of those points, and + CultLib would own neither. Semantically wrong for this campaign, not merely + unfashionable. +- **`@fails-components/webtransport` 1.6.8.** libquiche behind a WebTransport + (HTTP/3) session model `(probe)`. Wrong layer: the realtime plane is raw + QUIC with ALPN `cultmesh-state-v1`, not HTTP/3. +- **`node-quic` 0.1.3.** A wrapper around an abandoned pure-JS QUIC `(probe)`. + Not TLS 1.3 MsQuic; rejected on the same grounds. +- **A Node-API addon (node-addon-api + node-gyp/cmake-js) wrapping MsQuic + directly.** Viable, but it creates a second C++ owner of MsQuic callback + semantics beside the Unity bridge, requires Node headers and MSVC on every + Windows build host and a Node-ABI artifact per platform, and buys nothing the + C ABI plus FFI does not (P2). Rejected as duplicate authority. If koffi were + ever removed, the cheapest replacement is a thin N-API shim over the same C + ABI, not a second MsQuic body. +- **A separate .NET provider process or a WebSocket shortcut.** Already ruled + out by the operator (target, line 15-17). +- **JS callbacks from MsQuic worker threads.** koffi supports them by queuing + to the main thread (P4), but it turns every MsQuic event into a + cross-thread hop and opens the documented deadlock class. Rejected in favour + of the event-queue/wait model the existing bridge already uses. +- **Building the Windows DLL with the MinGW gcc on PATH.** Present (P6) but the + committed Unity artifact is MSVC `/Brepro`; two toolchains for one DLL is a + reproducibility liability. Rejected. +- **Building the Linux artifact on Starfire and shipping it.** Cross-building + is explicitly forbidden by the target; a Docker `debian:13` container on + Starfire is a Linux host and is fine for the development loop, but the + release artifact comes from the builder the operator names in Q6. +- **Keeping `cert-sha256` verification in C.** The v2 ABI hands the DER + certificate to the host and waits for the decision, which is the "caller + hook" the target demands and removes the Windows-only + `CryptHashCertificate2` from the portable path. The v1 entrypoint keeps its + in-C pin check only as the Unity compatibility shim (section 8). + +## 5. Authority maps + +### 5.1 Native MsQuic bridge (`native/GameCult.Mesh.Quic.Native`) + +- Owner: `cultmesh_quic_native.cpp` owns MsQuic registration, configuration, + listener, connection and stream handles, TLS credential loading, the + 1-byte-kind + 4-byte-LE-length stream framing, and a single event queue per + runtime. +- Inputs: host/port, PKCS12 bytes and password for providers, the host's + certificate decision for clients, encoded frame bytes to send. +- Outputs: events (`listener_new_connection`, `connection_connected`, + `connection_certificate_received` with DER payload, `connection_shutdown` + with code/status/text, `stream_started` with kind, `stream_frame` with the + whole encoded frame, `stream_send_complete`, `stream_shutdown`); the bound + listener port; structured error text. +- Derived state: none that the host reads as truth. Connection counts, remote + addresses and MsQuic status codes are diagnostics. +- Forbidden writers: the bridge never decodes a CultMesh frame, never decides + delivery semantics, never coalesces, never accepts a certificate on its own + in the v2 path, never reconnects. The v1 shim is the only place a pin is + compared in C, for the Unity contract only. +- Shared paths: the Unity managed connector (v1 exports) and the Node binding + (v2 exports) use the same library, the same MsQuic runtime, the same + framing code. +- Deletion line: the per-client `Client` struct with its own registration + (`:51-63`, `:316-393`), Windows-only includes and export macros (`:1-3`, + `:18-22`), `CertificateMatchesPin` as the sole trust path (`:184-200`). + +### 5.2 TypeScript QUIC realtime plane (`packages/cultmesh-ts`) + +- Owner: `src/realtime-quic.ts` owns provider and consumer semantics: which + stream kind carries which delivery mode, the per-peer keyed coalescing + outbox for `LatestOnly`, the single ordered stream for `ReliableOrdered`, + `Unreliable` failing closed, the generation filter on receive, and the + certificate pin decision on the consumer. +- Inputs: bridge events; `CultMeshRealtimeFrame` objects from application + code; an advertised `CultMeshTransportCandidate`-shaped route from the + session manager; the consumer's trust policy. +- Outputs: encoded frames to the bridge; decoded frames to the application; + the advertised endpoint string for a provider (with `cert-sha256`). +- Derived state: connection health, connected peer count, stream ids. +- Forbidden writers: application code never opens a stream, picks an + endpoint, or writes a reconnect loop (target). `src/realtime-wire.ts` never + touches a socket. `src/realtime-quic-native.ts` (the koffi binding) never + interprets frame bytes. +- Shared paths: provider broadcast and consumer send both go through + `sendFrame` on the same transport object; the coalescing outbox is the only + path for `LatestOnly` on a provider, whether the caller is a test, a tick, or + a reconnect. +- Deletion line: none inside `cultmesh-ts` (there is no prior QUIC code); the + RUDP provider transport at `src/provider-rudp-transport.ts` is untouched. + +### 5.3 Shared TypeScript route verification (Q2) + +- Owner after the cut: `packages/cultnet-ts/src/cultmesh-authority.ts`, + published as subpath `cultnet-ts/authority`. It is the only TypeScript + implementation of: Odin root lookup, validity window, canonical route + transcript, canonical session transcript, P-256/P1363 verification, + loopback detection, and the protected-scheme rule. +- Inputs: a route view (Verse, authority runtime, endpoint, protocol ids, + priority, generation, certificate), a trust policy (`mode`, `odinRoots`, + `now`), a session-open request and an accepted message. +- Outputs: resolution or a thrown `Error` with the same message texts the + browser package emits today (its tests pin them). +- Derived state: none. +- Forbidden writers: `cultmesh-browser` must not retain or regrow + `verifyAuthorityRoute`, `canonicalRoute`, `canonicalSession`, + `canonicalFields`, `verifyP256`, or `isLoopbackEndpoint`; `cultmesh-ts` must + not implement any of them; neither transport package may depend on the + other. Negative greps in section 6 pin this. +- Shared paths: the browser WebSocket client and the Node QUIC consumer verify + the same route bytes through the same function. +- Deletion line: `cultmesh-browser/src/index.ts:955-1072` and `:797-808`. +- Behavioural note, not a silent change: the protected-scheme rule becomes the + C# rule (`wss`, `https`, scheme containing `quic`; + `CultMeshAuthorityProof.cs:156-162`) instead of `wss`-only + (`cultmesh-browser/src/index.ts:965`). The browser client still refuses + non-`ws(s)` route endpoints at `:537-540`, so browser behaviour is + unchanged; the shared module simply stops being wrong for QUIC routes. + +### 5.4 Build and packaging + +- Owner: `scripts/build-quic-native.ps1` (Windows x64) and the new + `scripts/build-quic-native.sh` (Linux x64) own producing the bridge + artifact; `.github/workflows/publish-packages.yml` owns turning artifacts + into registry packages. +- Inputs: pinned MsQuic 2.5.9 (Schannel NuGet on Windows; `libmsquic` deb on + Linux) with SHA-256 checks; the bridge source. +- Outputs: `gamecult_mesh_quic_native.dll` + `msquic.dll` (win32-x64), + `libgamecult_mesh_quic_native.so` + `libmsquic.so.2` (linux-x64), each with + the MsQuic license. +- Derived state: `artifacts/` (git-ignored, `.gitignore:66`). +- Forbidden writers: no `postinstall` compile step in any published package; + a consumer host never needs CMake or a compiler. +- Deletion line: none; the Windows script is kept and the Unity package script + keeps calling it. + +## 6. Cut 1 (subtraction): one TypeScript route verifier + +Purpose: land Q2 before any QUIC code exists, so the Node consumer has one +owner to call and the browser package has nothing left to grow. + +Deletes (exact): + +- `packages/cultmesh-browser/src/index.ts:955-977` `verifyAuthorityRoute` + (23 lines), `:979-996` `canonicalRoute` (18), `:998-1010` `canonicalSession` + (13), `:1012-1026` `canonicalFields` (15), `:1028-1058` `verifyP256` (31), + `:1065-1072` `isLoopbackEndpoint` (8), `:797-803` `bytesToBase64` (7), + `:805-808` `base64ToBytes` (4). Total 119 lines deleted from the browser + package. `randomNonce` (`:1060-1063`) stays: it is browser-client behaviour, + not verification. + +Keeps and moves: + +- The deleted bodies move verbatim into + `packages/cultnet-ts/src/cultmesh-authority.ts` as exported functions, with + one edit: the protected-scheme test in `verifyAuthorityRoute` becomes + `isProtectedEndpoint(endpoint)` implementing the C# rule (P19). Types + `CultMeshBrowserIdentity`, `CultMeshBrowserRoute`, + `CultMeshBrowserP256PublicKey`, `CultMeshBrowserRouteCertificate`, + `CultMeshBrowserAuthorityTrustMode`, `CultMeshBrowserAuthorityTrustPolicy` + (`cultmesh-browser/src/index.ts:19-53`) move too, renamed without the + `Browser` infix (`CultMeshAuthorityIdentity`, `CultMeshAuthorityRouteView`, + `CultMeshP256PublicKey`, `CultMeshAuthorityRouteCertificate`, + `CultMeshAuthorityTrustMode`, `CultMeshAuthorityTrustPolicy`). + `cultmesh-browser` re-exports them under the old names as type aliases. +- `validateSessionAcceptance` (`:926-953`) stays in the browser package; it + compares WebSocket handshake fields. Its crypto lines (`:944-951`) are + replaced by a call to the new `verifyProviderSessionProof(request, endpoint, + providerKey, signature)` exported from the shared module. + +Adds: + +- `packages/cultnet-ts/src/cultmesh-authority.ts` (about 190 lines: the moved + 119 plus types and `isProtectedEndpoint`). Browser-safe by construction: it + may use only `globalThis.crypto.subtle`, `TextEncoder`, `atob`, `btoa`, + `URL`, `DataView`, `Uint8Array`. No `node:` import. +- `packages/cultnet-ts/package.json:7-18` gains an `"./authority"` export + beside `"./contracts"` (`:12-16`), same three-key shape. +- `packages/cultnet-ts/src/index.ts` re-exports the module's public names. +- `packages/cultnet-ts/test/cultmesh-authority.test.ts` (about 150 lines): + signs a route with `node:crypto` `generateKeyPairSync("ec", { namedCurve: + "P-256" })` and verifies through the shared module; one negative per rule + (expired, unsigned remote, wrong Odin root, mutated endpoint, wrong signature + length); and asserts `isProtectedEndpoint` accepts `wss://`, `https://`, + `cultmesh-state+quic://` and rejects `ws://`, `cultnet+tcp://`. +- A cross-runtime vector: `tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs` + gains a test that calls `CultMeshAuthorityProof.CreateSignedRoute` + (`src/GameCult.Mesh/CultMeshAuthorityProof.cs:221`) with a fixed test key + and writes `contracts/cultmesh/authority-route-vectors.json` (route fields, + Odin public key, signature) when `CULTMESH_WRITE_VECTORS=1`, and otherwise + asserts the committed file still verifies. The TS test verifies the same + file through the shared module. This is a shared-vector check, not + cross-process interop; it proves the transcript bytes, which is what Q2 + needs. + +Per-file changes: + +- `packages/cultmesh-browser/src/index.ts:1-17` add + `import { verifyAuthorityRoute, verifyProviderSessionProof, isLoopbackEndpoint, type ... } from "cultnet-ts/authority";` + (the package already imports `cultnet-ts/contracts` at `:2-17`, so + resolution is proven). `:536` and `:914` call the imported function + unchanged. `:944-951` call `verifyProviderSessionProof`. `:961` and `:965` + are gone with the function. +- `packages/cultmesh-browser/tsconfig.json` unchanged (`moduleResolution: + "Bundler"` already resolves the `exports` map). + +Verification: + +- `npm run test --workspace packages/cultnet-ts` (build + unit; pins the + shared verifier and the vector file). +- `npm run test --workspace packages/cultmesh-browser` (all 17 existing tests + at `test/cultmesh-browser.test.ts:29-472` must pass unchanged; they pin the + error strings and the refusal order, including "rejects a mutated or expired + signed route before opening a provider socket" at `:239-258`). +- `dotnet test tests/GameCult.Mesh.Tests --filter FullyQualifiedName~AuthorityProof` + (vector generation/verification; focused, one project). +- `node scripts/test-typescript-package-closure.mjs` (the packed + `cultmesh-browser` tarball must resolve `cultnet-ts/authority` from the + registry-shaped install at `scripts/test-typescript-package-closure.mjs:63-72`). +- Negative greps (must return nothing): + `rg -n "crypto\.subtle\.(verify|importKey)|function (verifyAuthorityRoute|canonicalRoute|canonicalSession|canonicalFields|verifyP256|isLoopbackEndpoint)" packages/cultmesh-browser/src packages/cultmesh-ts/src` + and `rg -n "cultmesh-browser" packages/cultmesh-ts/package.json packages/cultnet-ts/package.json`. +- Operator-only: none. + +## 7. Cut 2 (behaviour, pure TypeScript): the realtime frame codec + +Purpose: byte parity with `CultMeshRealtimeWireProtocol` before any transport +exists, proven in both directions with shared vectors. + +Deletes: none. + +Adds: + +- `packages/cultmesh-ts/src/realtime-wire.ts` (about 130 lines). Exports + `CULTMESH_REALTIME_ALPN = "cultmesh-state-v1"`, + `CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x43554c54n`, + `CULTMESH_REALTIME_STREAM_ABORT_CODE = 0x53544154n`, + `CULTMESH_REALTIME_RELIABLE_STREAM = 1`, `CULTMESH_REALTIME_LATEST_ONLY_STREAM = 2`, + `CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024`, + `CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES` (= max payload + 37 + 3 * 65535, + matching `CultMeshRealtimeWireProtocol.cs:20`), type + `CultMeshRealtimeDelivery = "reliable-ordered" | "latest-only" | "unreliable"` + with wire bytes 0/1/2 in that order (`CultMeshRealtimeTransports.cs:8-13`), + interface `CultMeshRealtimeFrame { channelId; schemaId; bodyId; + producerEpoch: bigint; sequence: bigint; delivery; payload: Uint8Array }`, + `encodeRealtimeFrame(frame): Uint8Array`, `decodeRealtimeFrame(bytes): + CultMeshRealtimeFrame`. Layout exactly `CultMeshRealtimeWireProtocol.cs:37-54` + (magic `0x31545343` LE u32, delivery u8, epoch i64 LE, sequence i64 LE, + three u16 LE lengths, payload i32 LE, header size i32 LE = 37, version u16 + LE = 1, then UTF-8 identities, then payload). Decode rejections match + `:60-76` one for one: truncated header, wrong magic, unsupported version or + header size, delivery out of range, negative payload length, length sum + mismatch. Epoch and sequence are `bigint` because i64 exceeds the safe + integer range; validation mirrors `CultMeshRealtimeTransports.cs:29-39` + (non-empty identities, non-negative epoch/sequence). +- `contracts/cultmesh/realtime-frame-vectors.json`: written by a new C# test + `tests/GameCult.Mesh.Tests/CultMeshRealtimeWireProtocolTests.cs` under + `CULTMESH_WRITE_VECTORS=1` and otherwise asserted against. Cases: one frame + per delivery mode with small payload; empty payload; identities at exactly + 65,535 UTF-8 bytes (multi-byte characters included so byte length and + character length differ); epoch and sequence at `long.MaxValue`; and a + 64 MiB-payload case recorded as `{ sha256OfEncoding, encodedLength }` rather + than bytes. Malformed cases: wrong magic, version 2, header size 36, + delivery 3, payload length -1, length sum off by one, each with the C# + exception message. There is no existing C# test for this codec + (`rg CultMeshRealtimeWireProtocol tests` returns nothing `(probe)`), so this + is also the first direct C# codec test. +- `packages/cultmesh-ts/test/realtime-wire.test.ts` (about 120 lines): every + vector decodes to the recorded fields and re-encodes to the recorded bytes; + every malformed vector throws; the 64 MiB case is built in-process and its + SHA-256 compared; TypeScript-produced bytes for the same fields equal the + C#-produced bytes (this direction is what "TypeScript encodings decode in + C#" means when the vector file is the medium; the cross-process direction is + cut 5). +- `packages/cultmesh-ts/src/index.ts:37-40` gains `export * from "./realtime-wire";`. + +Verification: + +- `dotnet test tests/GameCult.Mesh.Tests --filter FullyQualifiedName~RealtimeWireProtocol`. +- `npm run test --workspace packages/cultmesh-ts`. +- Negative grep: `rg -n "0x31545343|cultmesh-state-v1" packages --glob '!**/dist*/**'` + must hit only `realtime-wire.ts` and its test in TypeScript (one codec). +- Operator-only: none. + +## 8. Cut 3 (native, subtraction then behaviour): the runtime-neutral bridge + +Purpose: one MsQuic body for Unity and Node, two roles, two platforms, no +callbacks across the host boundary. + +Deletes first (in `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp`): + +- `:1-3` Windows includes; `:18-22` `__declspec` macros; `:51-63` per-client + `Client` with its own `api/registration/configuration`; `:184-200` + `CertificateMatchesPin` as the trust path; `:288-305` `CloseHandles`; + `:309-393` `cultmesh_quic_open` as the primary API. About 200 of 440 lines + are removed or rewritten; the framing consumer (`:123-157`), stream callback + (`:159-182`), trace/error helpers (`:65-86`, `:202-221`) are kept in spirit + and re-homed on the runtime. +- `CMakeLists.txt:4-6` (Windows-only fatal) and `:7-9` (`MSQUIC_ROOT` + mandatory) are replaced by a platform branch. + +Adds (C ABI v2, all `extern "C"`, exported with a portable macro +`CULTMESH_API` that is `__declspec(dllexport)` on MSVC and +`__attribute__((visibility("default")))` elsewhere): + +``` +int32_t cultmesh_quic_runtime_open(const char* app_name, void** out_runtime); +void cultmesh_quic_runtime_close(void* runtime); +int32_t cultmesh_quic_listener_open(void* runtime, const char* host, uint16_t port, + const uint8_t* pkcs12, int32_t pkcs12_len, const char* password, + uint64_t* out_listener_id, uint16_t* out_bound_port); +void cultmesh_quic_listener_close(void* runtime, uint64_t listener_id); +int32_t cultmesh_quic_connection_open(void* runtime, const char* host, uint16_t port, + uint64_t* out_connection_id); +int32_t cultmesh_quic_connection_certificate_complete(void* runtime, uint64_t connection_id, + int32_t accept); +void cultmesh_quic_connection_shutdown(void* runtime, uint64_t connection_id, uint64_t code); +int32_t cultmesh_quic_stream_open(void* runtime, uint64_t connection_id, uint8_t kind, + uint64_t* out_stream_id); +int32_t cultmesh_quic_stream_send_frame(void* runtime, uint64_t stream_id, + const uint8_t* encoded_frame, int32_t length, int32_t fin); +void cultmesh_quic_stream_shutdown(void* runtime, uint64_t stream_id, uint64_t code); +int32_t cultmesh_quic_next_event(void* runtime, int32_t timeout_ms, + cultmesh_quic_event* out_event, uint8_t* payload, int32_t payload_capacity, + int32_t* out_required); +int32_t cultmesh_quic_last_error(void* runtime, char* destination, int32_t capacity); +``` + +- `cultmesh_quic_event` is a fixed 64-byte struct: `uint32 type; uint32 + stream_kind; uint64 listener_id; uint64 connection_id; uint64 stream_id; + uint64 code; int32 status; int32 payload_length; uint8 reserved[16]`. + Types: 1 `listener_new_connection`, 2 `connection_connected`, 3 + `connection_certificate_received` (payload = DER), 4 `connection_shutdown` + (payload = UTF-8 reason), 5 `stream_started` (inbound, `stream_kind` set + from the first byte), 6 `stream_frame` (payload = one whole encoded frame, + length prefix stripped), 7 `stream_send_complete`, 8 `stream_shutdown`, 9 + `listener_stopped`. `next_event` returns 0 on timeout, 1 with the event + filled and payload copied, 2 when `payload_capacity` is too small (event + stays at the head; `out_required` set), negative on error. This is the v1 + two-phase poll convention (`:400-418`) applied to a queue of typed events. +- Client connections use `QUIC_CREDENTIAL_TYPE_NONE` with + `INDICATE_CERTIFICATE_RECEIVED | DEFER_CERTIFICATE_VALIDATION | + USE_PORTABLE_CERTIFICATES` (already at `:358-364`), return + `QUIC_STATUS_PENDING` from the certificate event, enqueue event 3, and call + `ConnectionCertificateValidationComplete` only when the host calls + `certificate_complete` (P11: `msquic.h:132-133,142,1290`). Timeout: the + bridge sets a 10 s handshake idle; if the host never answers, MsQuic's + handshake timeout closes the connection and event 4 is emitted. +- Listeners load `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12` + (`msquic.h:123,394-398`), one credential type for Schannel and OpenSSL; the + provider's advertised pin is the SHA-256 of the DER certificate computed on + the TypeScript side from the same PKCS12 with `node:crypto` (no hashing in + C). +- Settings mirror the C# server/connector: `PeerUnidiStreamCount 1024` + (`CultMeshQuicRealtimeTransport.cs:77`, `:202`), idle timeout 30 s and + keep-alive 5 s as in the existing bridge (`:335-338`). +- `stream_send_frame` copies the bytes, prepends the 4-byte LE length, calls + `StreamSend` (with `QUIC_SEND_FLAG_FIN` when `fin`), and emits event 7 on + `QUIC_STREAM_EVENT_SEND_COMPLETE`. `stream_open` writes the kind byte as the + first send. Framing therefore lives in C on both directions, exactly where + the v1 bridge already keeps it (`:123-157`), and `CultMeshQuicRealtimeProtocol` + keeps it in C# (`CultMeshQuicRealtimeTransport.cs:635-655`). +- The v1 exports `cultmesh_quic_open/state/poll/error/close` are kept, Windows + only (`#ifdef _WIN32`), implemented on top of a private runtime with the pin + check retained from `:184-200`. External contract protected: the Unity + package `DllImport` at `CultMeshNativeQuicRealtimeTransport.cs:259-277` and + `CanConnect`'s Windows gate at `:39-41`. They delegate; they decide nothing + new. +- `CMakeLists.txt`: Windows branch unchanged in effect (MSVC, `/Brepro`, + `MSQUIC_ROOT` from the NuGet layout, links `msquic crypt32 bcrypt`); Linux + branch links `msquic` from `MSQUIC_LIB_DIR` (the extracted deb's + `usr/lib/x86_64-linux-gnu`) with headers from `MSQUIC_INCLUDE_DIR`, `-fvisibility=hidden`, + output `libgamecult_mesh_quic_native.so`, `RPATH $ORIGIN` so the sibling + `libmsquic.so.2` resolves. +- `scripts/build-quic-native.sh` (about 70 lines): fetches + `libmsquic_2.5.9_amd64.deb` from the packages.microsoft.com pool URL in P7, + verifies its SHA-256, extracts with `dpkg-deb -x`, fetches the three headers + at tag `v2.5.9` (P10) and verifies their SHA-256 (record the hashes at first + run into the script, the same way `build-quic-native.ps1:14` pins the + NuGet), configures CMake with Ninja, and copies `libgamecult_mesh_quic_native.so`, + `libmsquic.so.2`, and the MsQuic LICENSE into `artifacts/quic-native/linux-x64`. +- `native/GameCult.Mesh.Quic.Native/README.md` rewritten to describe both + roles, both platforms, the v1/v2 split, and the "no host callbacks" rule. + +Per-file changes outside `native/`: + +- `scripts/build-quic-native.ps1:54-58` output path becomes + `artifacts/quic-native/win32-x64` by default (parameter default at `:4`); + callers `build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` + pass explicit `-OutputDirectory` today, so they are unaffected. +- No change to `src/GameCult.Mesh.Quic.Native/*.cs`. The Unity managed + connector keeps polling v1. + +Verification: + +- Windows: `powershell -File scripts/build-quic-native.ps1` then + `dotnet test tests/GameCult.Mesh.Quic.Native.Tests` (the csproj rebuilds the + bridge itself at `:20-22`; all four tests at + `CultMeshNativeQuicRealtimeTransportTests.cs:15-141` must pass, including the + wrong-pin refusal at `:79-99` and the departed-client test at `:101-141`). + This is the regression gate for the v1 shim. +- Windows FFI smoke, before any TypeScript transport exists: + `scratchpad`-style script (to be committed as + `packages/cultmesh-ts/test/realtime-quic-native.test.ts` in cut 4) that + opens a runtime, a listener with a PKCS12 generated by `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 ... | openssl pkcs12 -export` + (P13), a client connection to it, answers event 3 with accept, sees event 2 + on both sides, opens a latest-only stream, sends one frame with `fin`, and + reads event 6 on the server side with the identical bytes. +- Linux, development loop: `docker run --rm -v F:\Projects\CultLib:/src debian:13 bash -lc "apt-get update && apt-get install -y cmake ninja-build g++ curl ca-certificates dpkg && /src/scripts/build-quic-native.sh"` + then the same FFI smoke under `node:24` in the container. This is a Linux + build on a Linux host; it is not the release artifact (Q6). +- Build economy: the only builds are the native CMake target and + `tests/GameCult.Mesh.Quic.Native.Tests` (which pulls `GameCult.Mesh.Quic`, + `GameCult.Mesh.Quic.Native`, `GameCult.Mesh`, `GameCult.Networking`, + `GameCult.Caching` by project reference). Before the first `dotnet build`, + take `Get-ChildItem -Recurse F:\Projects\CultLib\bin,F:\Projects\CultLib\obj,F:\Projects\CultLib\artifacts | Select FullName,Length` + to a scratch file and diff afterwards; restore or delete anything outside + those five projects' outputs. +- Negative greps: `rg -n "CryptHashCertificate2|wincrypt" native/` must hit + only inside the `#ifdef _WIN32` v1 block; `rg -n "windows.h" native/` only + inside `#ifdef _WIN32`. +- Operator-only: rebuilding and re-committing the Unity plugin DLL + (`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`) + and cutting a Unity package version through `scripts/build-unity-package.ps1` + is a release action; the map does not schedule it. Until it happens, Unity + keeps the old DLL, which is fine because v1 exports are unchanged. + +## 9. Cut 4 (behaviour): Node binding, consumer, session manager, trust + +Purpose: a TypeScript consumer that connects by advertised candidate, verifies +the Odin route through the cut-1 module, pins the TLS certificate, and +receives frames from the C# managed provider in a separate process. + +Deletes: none. + +Adds: + +- `packages/cultmesh-ts/src/realtime-quic-native.ts` (about 260 lines): the + koffi binding. Resolves the platform package (`@gamecult/cultmesh-quic-native-win32-x64` + or `-linux-x64`, Q5) or, when `CULTMESH_QUIC_NATIVE_DIR` is set, a directory + (this is how the repo's own tests point at `artifacts/quic-native/` + without a registry). Loads `msquic` first, then the bridge, by absolute path + (the probe showed the dependent library must be resolvable before the + bridge loads). Declares the twelve v2 prototypes. Exposes + `CultMeshQuicNativeRuntime` with a single event pump: a loop that awaits + `nextEvent.async(runtime, 250, ...)` and dispatches typed events to + registered listeners/connections by id. One pump per process-wide runtime; + reference-counted open/close. No koffi callbacks anywhere. +- `packages/cultmesh-ts/src/realtime-quic.ts` (about 650 lines across cuts 4 + and 5). Cut 4 lands: `CultMeshQuicRealtimeTransport` (implements + `sendFrame`, `receiveFrame`, `dispose`, `transportId = "msquic-realtime"`, + `endpoint`; the `isVerifiedFor(verseId, authorityRuntimeId, protocolId, + routeGeneration)` check mirroring `CultMeshQuicRealtimeTransport.cs:352-361`), + `CultMeshQuicRealtimeConnector` (`connectorId`, `priority`, `canConnect(candidate)`, + `connect(candidate, target, signal)`), and the receive path: stream kind + vs frame delivery consistency (`:519-524`), and the per-`(channel, body)` + generation filter that drops older `(producerEpoch, sequence)` (`:538-560`). + Certificate decision on event 3: caller `validateProviderCertificate(target, + der)` if supplied, else SHA-256 of DER equals the endpoint's `cert-sha256` + (`:111-119`, case-insensitive hex); otherwise reject. Endpoint parsing: + scheme `cultmesh-state+quic`, host, port, optional pin (`:98-109`). +- `CultMeshQuicRealtimeSessionManager` in the same file: takes an + `ICultMeshRealtimeLookupSource`-shaped port `{ resolve(target): Promise }`, + a trust policy from `cultnet-ts/authority`, and connectors. Binds + candidates from `authorityRoutes` whose `protocolIds` include + `cultmesh.realtime_state.v1` (`CultMeshSessions.cs:77`) and whose + `authorityRuntimeId` matches; verifies each with `verifyAuthorityRoute` + before any connector is asked; orders by connector priority then route + priority; races at most `maxRacedCandidates` per tier (`:833-880`); requires + `isVerifiedFor` after connect (`:885-896`); keys sessions by + `verseId + "\u001f" + authorityRuntimeId` (`:46`); marks offline on transport + failure. Ships with `CultMeshStaticRealtimeLookupSource` (an array of + `CultMeshVerseDescriptorMessage` from `cultnet-ts/contracts:350-369`). No + Odin WebSocket rendezvous in Node in this migration (Q7). +- `CultMesh` facade (`packages/cultmesh-ts/src/index.ts:4700`) gains statics + beside the RUDP ones at `:6008-6186`: `parseQuicRealtimeEndpoint`, + `createQuicRealtimeConnector(options)`, `createQuicRealtimeSessionManager(options)`, + and in cut 5 `createQuicRealtimeProvider(options)`. +- `packages/cultmesh-ts/package.json:44-48` gains `"koffi": "3.3.0"` (exact) + and `optionalDependencies` for the two platform packages (names per Q5). + `koffi` is required lazily inside `realtime-quic-native.ts` so consumers + that never touch QUIC (AetheriaEve Electron, Odin, Stonks, weksa) load + nothing native. +- `packages/cultmesh-ts/test/realtime-quic-native.test.ts` (about 150 lines): + the cut-3 FFI smoke, now against the runtime class. +- `packages/cultmesh-ts/test/realtime-quic-consumer.test.ts` (about 250 + lines): TypeScript consumer against a TypeScript listener opened through + the raw runtime (no provider semantics yet): connect with correct pin; + refuse wrong pin (`cert-sha256` of 64 zeros, mirroring + `CultMeshNativeQuicRealtimeTransportTests.cs:79-99`); refuse missing + certificate when no validator; caller validator observed with the target + identity; generation filter drops a late older generation (mirrors + `CultMeshQuicRealtimeTransportTests.cs:111-140`). +- Trust negatives, each a separate test with its own mutation, in the same + file, through the session manager with a static lookup source and a signed + route built with `node:crypto`: (a) expired certificate (`now` past + `expiresAt`); (b) unsigned remote route (certificate absent, mode + `authenticated-remote`); (c) wrong Odin root (trust has a different + `keyId`); (d) `cert-sha256` pin mismatch (route verifies, TLS pin fails, so + the connector rejects before `isVerifiedFor`); (e) replayed nonce. On the + QUIC plane there is no `session_open` nonce exchange; the route's + freshness is its generation plus the TLS handshake. The replay test + therefore mutates `generation` on the connected transport's target and + asserts `isVerifiedFor` fails and the session manager disposes the + transport (`CultMeshSessions.cs:885-896`). If the operator wants a nonce on + QUIC, that is a protocol addition on both runtimes and belongs to a later + pipeline; this map does not invent it. +- Interop lane, C# managed provider to TypeScript consumer, separate + processes: `tests/GameCult.Networking.InteropPeer/Program.cs:27-49` gains + mode `quic-realtime-serve` (`--port`, `--frames N`, `--delivery + latest-only|reliable-ordered`, `--interval-ms`): creates a self-signed + certificate exactly as `CultMeshQuicRealtimeTransportTests.cs:203-228`, + starts `CultMeshQuicRealtimeServer`, prints one JSON line + `{ endpoint: "cultmesh-state+quic://127.0.0.1:?cert-sha256=" }` + to stdout, then broadcasts `N` frames with increasing sequence and exits + after the last `send_complete` or on stdin close. + `GameCult.Networking.InteropPeer.csproj:9-11` gains a `ProjectReference` to + `src/GameCult.Mesh.Quic/GameCult.Mesh.Quic.csproj`. + `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` gains + `test("CultMesh QUIC realtime: C# managed provider and TypeScript consumer", ...)` + using `spawnServeProcess` (`:2491`) for the C# peer and `runJsonCommand` + (`:2557`) for a new TypeScript peer script + `packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts` (`dial --endpoint + --expect N`) that prints the received frames as JSON (fields plus payload + hex). The harness drives processes; it does not import `cultmesh-ts` + (dependency direction: `cultmesh-ts` depends on `cultnet-ts`, so the reverse + import is forbidden). The peer script path follows the `tsPeerScript` + pattern at `:41`. + +Verification: + +- `npm run test --workspace packages/cultmesh-ts` with + `CULTMESH_QUIC_NATIVE_DIR=artifacts/quic-native/win32-x64`. +- `npm run test:interop --workspace packages/cultnet-ts` filtered with + `--test-name-pattern "QUIC realtime"`; it builds the C# interop peer through + the existing `csharpInteropPeerBuild` path (`:45-58`, `:71`). +- Negative greps: `rg -n "new Function|koffi\.register|\.async\(" packages/cultmesh-ts/src` + must show `.async(` only on `nextEvent` in `realtime-quic-native.ts` and no + `koffi.register` (no callbacks); + `rg -n "verifyP256|canonicalRoute" packages/cultmesh-ts/src` must return + nothing. +- Operator-only: none. + +## 10. Cut 5 (behaviour): TypeScript provider and the coalescing outbox + +Purpose: the StreamPixels role. A TypeScript listener that broadcasts with an +explicit delivery mode and cannot be backpressured by a slow consumer, proven +against both C# connectors in separate processes. + +Deletes: none. + +Adds (in `packages/cultmesh-ts/src/realtime-quic.ts`): + +- `CultMeshQuicRealtimeProvider.listen({ host, port, serverCertificate: + { pkcs12, password }, handshakeTimeoutMs })`: opens a listener, computes + `advertisedEndpoint` (`cultmesh-state+quic://:?cert-sha256=`, + uppercase, because the Unity connector requires the pin, P18), accepts + connections into a peer set, exposes `connectionCount`, `broadcast(frame)`, + `receive()` for client-originated frames, `dispose()`. +- Delivery semantics, mirroring `CultMeshQuicRealtimeTransport.cs`: + `reliable-ordered` opens one unidirectional stream of kind 1 per peer on + first use and serialises writes behind a per-peer promise chain; `broadcast` + resolves when every peer's `send_complete` arrives or the peer is evicted + (`:216-235`, `:237-255`). `latest-only` enqueues into a per-peer keyed + outbox (`Map<"channel\u001fbody", frame>` plus a ready queue) and returns + synchronously (`:363-367`, `:572-618`); each peer's pump opens a fresh kind-2 + stream per frame, sends with `fin`, and only dequeues the next frame after + `send_complete`, so a stalled peer accumulates at most one pending frame per + `(channel, body)` and never blocks `broadcast`. `unreliable` throws + `NotSupported` with the same sentence as `:373-375` adjusted for the + runtime. +- Peer eviction on send failure or `connection_shutdown`; no reconnect logic + on the provider. + +Interop lanes (all separate processes; all in +`packages/cultnet-ts/test/interop/cultnet-interop.test.ts`): + +- TypeScript provider to C# native connector (the Unity one): the harness + starts `cultmesh-quic-peer.ts serve --frames 5 --delivery latest-only`, + reads its advertised endpoint from stdout, then runs + `dotnet test tests/GameCult.Mesh.Quic.Native.Tests --filter FullyQualifiedName~NativeConnectorReceivesFromExternalManagedProvider` + with `CULTMESH_NATIVE_EXTERNAL_ENDPOINT=` (the existing test at + `CultMeshNativeQuicRealtimeTransportTests.cs:15-33` is reused unchanged: it + already asserts a frame arrives with non-empty schema, body and payload). + Rename the test to `NativeConnectorReceivesFromExternalProvider` since the + provider is no longer necessarily managed; the env var name stays. +- TypeScript provider to C# managed connector: add + `ManagedConnectorReceivesFromExternalProvider` to + `tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs`, + gated by the same `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`, using + `CultMeshQuicRealtimeTransportConnector` with the pin from the endpoint + (`:111-119`) and no validator, asserting frame fields and payload bytes + equal what the TypeScript peer prints. The harness runs it the same way. +- `LatestOnly` under a stalled consumer: `cultmesh-quic-peer.ts serve + --frames 200 --delivery latest-only --interval-ms 1` while the harness dials + twice with the TypeScript consumer, pauses one consumer's pump for 500 ms + (a test hook on the runtime that stops calling `next_event`), and asserts: + the provider's 200 `broadcast` calls complete within a bounded time + regardless of the pause; the healthy consumer reaches sequence 199; the + paused consumer, once resumed, converges on 199 with fewer than 200 frames + received. This is the TypeScript form of + `CultMeshNativeQuicRealtimeTransportTests.cs:101-141` plus the convergence + assertion from `CultMeshQuicRealtimeTransportTests.cs:111-140`. +- Golden bytes across processes: the C# managed connector lane above prints + the received frame's re-encoding (via `CultMeshRealtimeWireProtocol.EncodeFrame`) + as hex; the harness compares it with the TypeScript peer's `encodeRealtimeFrame` + output for the same frame. That closes "TypeScript encodings decode in C#" + by process, not by shared file. + +Verification: + +- `npm run test --workspace packages/cultmesh-ts` (provider unit tests with + the TypeScript consumer in-process but separate connections: reliable + ordering in both directions mirrors `CultMeshQuicRealtimeTransportTests.cs:71-110`; + latest-only coalescing; unreliable fails closed). +- `npm run test:interop --workspace packages/cultnet-ts --test-name-pattern "QUIC realtime"`. +- `dotnet test tests/GameCult.Mesh.Quic.Tests` and + `dotnet test tests/GameCult.Mesh.Quic.Native.Tests` without the env var + (the new tests `Assert.Ignore`, the existing ones still pass). +- Negative greps: `rg -n "setInterval|setTimeout" packages/cultmesh-ts/src/realtime-quic.ts` + must show no polling of the bridge (the pump is the only wait); + `rg -n "reconnect" packages/cultmesh-ts/src/realtime-quic.ts` must return + nothing on the provider side. +- Operator-only: none. + +## 11. Cut 6 (packaging and release) + +Purpose: a consumer host with no toolchain installs the binding. + +Adds: + +- `packages/cultmesh-quic-native/win32-x64/package.json` and + `packages/cultmesh-quic-native/linux-x64/package.json` (names per Q5), each + with `os`/`cpu` fields, `files` listing the two binaries and + `MSQUIC-LICENSE.txt`, no scripts, and a `README.md` naming the MsQuic + version and the build host. Their contents are copied from + `artifacts/quic-native/` by the publish job, never committed. +- `.github/workflows/publish-packages.yml`: tag prefixes `cultnet-ts-v*`, + `cultmesh-ts-v*`, `cultmesh-quic-native-v*` (`:20-24`); a `cultnet-ts` job + and a `cultmesh-ts` job shaped like the `cultcache-ts` job (`:40-76`); a + native job with two runners: `windows-latest` running + `scripts/build-quic-native.ps1`, and `ubuntu-latest` with + `container: debian:13` running `scripts/build-quic-native.sh` (Q6), each + publishing its platform package, then the `cultmesh-ts` job depends on both. +- `scripts/test-typescript-package-closure.mjs:9` adds the platform packages + to the pack list on the matching host and `:63-72` adds + `assert.equal(typeof mesh.CultMesh.createQuicRealtimeConnector, "function")` + plus a runtime smoke that opens and closes a `CultMeshQuicNativeRuntime` + from the installed tarballs (proves the optional dependency resolves and the + binaries load from `node_modules`, not from a repo path). +- `.github/workflows/cultnet-interop.yml` gains a step after the C# RUDP + step that builds the Windows bridge (`build-quic-native.ps1`) and runs the + `QUIC realtime` lanes with `CULTMESH_QUIC_NATIVE_DIR` set. Linux CI + coverage of the lanes needs a Linux runner with dotnet and the deb; add a + second job `interop-quic-linux` on `ubuntu-latest` + `container: debian:13` + running only the QUIC lanes. +- `README.md` of `packages/cultmesh-ts` gains a "QUIC Realtime Plane" section + after "RUDP Helpers" (`README.md:332`) with provider and consumer examples + matching the C# README (`src/GameCult.Mesh.Quic/README.md:13-62`). + +Verification: + +- `node scripts/test-typescript-package-closure.mjs` on Windows. +- Manual dispatch of `publish-packages.yml` with `package=cultmesh-ts` builds + and tests without publishing (`:24-32` semantics). +- Operator-only: the tag pushes that publish; `NPM_TOKEN` scope for new + package names; and the Yggdrasil verification: `ssh gamecultadmin@yggdrasil.gamecult.org`, + `mkdir /tmp/cultmesh-quic-smoke && cd $_ && npm init -y && npm i cultmesh-ts@`, + then `node -e "require('cultmesh-ts').CultMesh.createQuicRealtimeProvider({...}).then(p => { console.log(p.advertisedEndpoint); return p.dispose(); })"` + with a throwaway PKCS12 from `openssl`, followed by a loopback dial from a + second `node` process on the same host. `libmsquic`'s runtime dependencies + (`libssl3t64`, `libnuma1`, `libxdp1`, `libnl-route-3-200`, P7) must be + present on Yggdrasil; the smoke will say so if not. This is the only place + the Linux artifact is proven on the deploy host, and only the operator holds + the key. + +## 12. Cut 7 (documentation and ledgers) + +- `docs/runtime-parity-scope.md:38` TypeScript row: add "CultMesh QUIC + realtime provider and consumer over CultLib-owned MsQuic bindings (Windows + x64, Linux x64), byte-parity with C#, cross-process interop lanes" to + "Claimed parity"; add "QUIC datagrams (`Unreliable`), browser QUIC" to "Not + claimed". +- `src/GameCult.Mesh/docs/transport-planes.md:35-46`: name the TypeScript + provider/consumer beside the .NET and Unity bodies; `:122-125`: state that + `CULTMESH_NATIVE_EXTERNAL_ENDPOINT` now accepts any provider and is driven + by the TypeScript interop harness. +- `docs/cultnet-transport-parity.md`: no change. It maps RUDP/TCP/LiteNetLib + ownership; the realtime plane is a CultMesh plane, not a CultNet transport + profile, and pretending otherwise would put QUIC into a document whose cut + line says "Do not put UDP packet mechanics into CultMesh" the other way + round. +- `docs/parked-features.md`: one entry, "Node Odin WebSocket rendezvous", + pointing at `cultmesh-browser/src/index.ts:84-191` as the browser owner and + the `ICultMeshRealtimeLookupSource` port as the seam (Q7). +- `docs/typescript-quic-realtime-target.md:5-7`: status line updated to point + at this map. + +Verification: `rg -n "CultMeshRealtimeTransports.cs" docs src/GameCult.Mesh/docs` +returns only correct paths. + +## 13. Operator questions + +The four rulings in the target (Q1-Q4) are closed and not reopened. These are +the forks the probes surfaced. + +- **Q5. Registry names.** The binding cannot ship as prebuilt platform + packages without `cultmesh-ts` itself being on a registry, and today every + consumer uses `file:` paths and only `@gamecult/cultcache-ts` is published + (P15, P16). All candidate names are unclaimed (P14). A: publish `cultnet-ts` + and `cultmesh-ts` under their existing unscoped names (no import changes in + any of the 11 consuming repos) and put only the new native platform packages + under `@gamecult/cultmesh-quic-native-{win32-x64,linux-x64}`. B: scope + everything as `@gamecult/*` now and take the import rename across consumers + as a separate consumer task. C: no registry; ship the binaries inside the + CultLib tarball the deploy script already extracts on Yggdrasil + (`deploy-streampixels-preview.sh:95-99`), keeping `file:` consumption. + **Recommended: A.** It reshapes nothing consumers use, matches the + `cultcache-ts` precedent of scoping only what is new, and gives StreamPixels + a versioned artifact instead of a tarball with a `.so` in it. C is the + cheapest but leaves the deploy path owning a native binary by hand, which is + the failure the target's "shipped through a registry" phrase was written to + avoid. +- **Q6. Linux release builder.** A: GitHub Actions `ubuntu-latest` with + `container: debian:13` (same OS as Yggdrasil, P8), artifact published by the + workflow, verified on Yggdrasil by the operator smoke in cut 6. B: build on + Yggdrasil in the deploy script (installs CMake and g++ on a live public + host; every deploy compiles). C: build on Starfire's Docker `debian:13` + engine (P12) and publish from the workstation. **Recommended: A.** It is a + Linux host, it is reproducible from a tag, and it keeps compilers off + Yggdrasil. C is kept for the development loop only. +- **Q7. Node-side Odin rendezvous.** The TypeScript consumer needs a route + source. The browser package has an Odin WebSocket rendezvous + (`cultmesh-browser/src/index.ts:84-191`); Node 24 has a global `WebSocket`, + so it would run, but `cultmesh-ts` may not depend on `cultmesh-browser` + (Q2's spirit), and the first consumer of the Node QUIC path is the interop + harness, not a product. A: ship the lookup-source port plus a static source + now, park the Node Odin rendezvous with a note. B: move the Odin rendezvous + into `cultnet-ts` beside the verifier in this migration so Node and browser + share it. **Recommended: A.** B is the right end state but is a second + ownership move in one pipeline; it should follow once a Node consumer that + discovers through Odin exists. + +Not asked, because the target already decided them: datagrams stay out (Q4); +no TLS on TCP planes (Q1); Windows x64 and Linux x64 only (Q3); the verifier +moves to a shared browser-safe module (Q2, section 6 names the module). + +## 14. Subtraction estimate + +Both sides of the number, by cut, in source lines (tests included, generated +`dist*` excluded, vector JSON excluded): + +| Cut | Removed or replaced | Added | Net | +| --- | ---: | ---: | ---: | +| 1 trust module | 119 (browser) | 190 (module) + 150 (tests) + 40 (C# vector test) | +261 | +| 2 codec | 0 | 130 + 120 (TS test) + 120 (C# test) | +370 | +| 3 bridge | ~200 rewritten of 440 C++; 24 CMake | ~1,000 C++ (replacing 440), 60 CMake, 70 sh, 40 README | +~700 net C++/build | +| 4 consumer | 0 | 260 (binding) + 400 (consumer/session manager) + 400 (tests) + 200 (C# interop mode) + 150 (harness) + 120 (peer script) | +1,530 | +| 5 provider | 0 | 250 (provider) + 300 (tests) + 150 (harness) + 40 (C# tests) | +740 | +| 6 packaging | 0 | 60 (package manifests) + 130 (workflow) + 30 (closure smoke) + 60 (README) | +280 | +| 7 docs | ~10 | ~40 | +30 | +| Total | ~350 | ~4,260 | about +3,900 | + +Dependencies: +1 runtime dependency on `cultmesh-ts` (`koffi`, 1.7 MB, MIT, +loaded lazily); +2 optional platform packages; +0 build-time Node +dependencies (no node-gyp, no node-addon-api, no cmake-js). Targets: +0 .NET +projects (the C# interop peer gains one project reference); +1 CMake platform +branch; +1 shell build script; +3 publish jobs; +1 CI job. + +Why the number is positive and still acceptable: the target orders a new +capability (a QUIC realtime plane in a runtime that has none), and every line +above either is that capability, proves it against the C# reference, or ships +it. The subtraction that was available (one verifier instead of two, one +MsQuic body instead of two, one framing implementation per language instead +of one per host) is taken in cuts 1 and 3. Anything that would have shrunk +the number further (a third-party QUIC stack, a callback-driven addon, a +tarball with a `.so`) was rejected in section 4 for reasons that are not +about line count. + +## 15. Not assignable to a cut + +- **The Unity package release** carrying the rebuilt bridge DLL. The v1 ABI + is preserved, so no cut requires it, but the committed DLL will lag the + source until the operator runs `scripts/build-unity-package.ps1` and + commits. Recorded here so nobody reads the stale DLL as evidence the + rewrite did not happen. +- **StreamPixels certificate provisioning** (which PKCS12, where it lives on + Yggdrasil, how its pin reaches the Odin route). Consumer work by the target. +- **A nonce on the QUIC plane.** The target lists "a replayed nonce" among + trust negatives, but the QUIC plane has no nonce exchange in C# either + (`CultMeshQuicRealtimeTransport.cs` opens no session message; trust is the + Odin route plus the TLS pin, `transport-planes.md:71-73`). Cut 4 tests the + replay class that does exist (route generation). Adding a nonce is a + cross-runtime protocol change and needs its own target. +- **Linux coverage of the C# native connector.** `CanConnect` is Windows-only + (`CultMeshNativeQuicRealtimeTransport.cs:40`); the Linux interop job in cut 6 + therefore runs the TypeScript-provider-to-managed-connector lane and the + TypeScript-to-TypeScript lanes only. The native-connector lane is Windows + by design, matching where Unity runs. +- **The `cultcache-ts` naming split** across consumers (section 3). Out of + scope; noted because Q5's answer should not make it worse. + +## 16. Reminder of what a wrong cut looks like + +If a cut touches `src/GameCult.Mesh/CultMeshTcp*.cs`, adds TLS to the TCP +schema or content connectors, adds WebTransport to `cultmesh-browser`, adds +QUIC to `cultnet-py`, `cultnet-rs`, or `cultmesh-kotlin`, or edits anything +under `F:\Projects\StreamPixels`, it has left the target's "Not in this +migration" list and is wrong. Stop and re-read this map.