diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml
new file mode 100644
index 00000000..ce361fc8
--- /dev/null
+++ b/.github/workflows/cultmesh-portability.yml
@@ -0,0 +1,97 @@
+name: CultMesh portability and performance
+
+on:
+ push:
+ branches:
+ - main
+ - "codex/**"
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ getting-started:
+ name: ${{ matrix.os }} one-command multiplayer onboarding
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, ubuntu-latest]
+
+ steps:
+ - name: Check out CultLib
+ uses: actions/checkout@v4
+ with:
+ path: CultLib
+
+ - name: Check out the matching Eve contract
+ uses: actions/checkout@v4
+ with:
+ repository: GameCult/Eve
+ # Eve main is the contract this sample is written against; the codex
+ # branch this used to pin was never merged and stopped on 2026-08-25.
+ ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }}
+ path: Eve
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
+ - name: Run the documented one-command checkpoint
+ shell: pwsh
+ run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve
+
+ reactive-and-resource-lifetime:
+ name: ${{ matrix.os }} reactive semantics and leases
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, ubuntu-latest]
+
+ steps:
+ - name: Check out CultLib
+ uses: actions/checkout@v4
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install TypeScript workspace dependencies
+ run: npm install --no-package-lock
+
+ - name: Install Python CultLib packages
+ run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
+
+ - name: Verify explicit reactive scheduling across runtimes
+ shell: pwsh
+ run: ./scripts/verify-reactive-document-scaling.ps1
+
+ - name: Measure representative reactive workload
+ shell: pwsh
+ run: ./scripts/measure-reactive-document-performance.ps1 -Quick -SkipRuntimeBuild
+
+ - name: Churn 100,000 distinct document leases
+ run: >-
+ dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj
+ --filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks
+ --logger "console;verbosity=minimal"
diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml
index 1bde67ef..6876a76a 100644
--- a/.github/workflows/cultnet-interop.yml
+++ b/.github/workflows/cultnet-interop.yml
@@ -33,10 +33,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 24
- cache: npm
- cache-dependency-path: |
- packages/cultcache-ts/package-lock.json
- packages/cultnet-ts/package-lock.json
- name: Set up Python
uses: actions/setup-python@v5
@@ -73,7 +69,7 @@ jobs:
run: npm install --no-package-lock
- name: Install Python package dependencies
- run: python -m pip install -e packages/cultcache-py
+ run: python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
- name: Build TypeScript workspace dependencies
run: npm run build --workspace packages/cultcache-ts
@@ -87,6 +83,24 @@ jobs:
npm run build --workspace packages/cultnet-ts
npx tsc -p packages/cultnet-ts/tsconfig.test.json --pretty false
+ - name: Verify TypeScript RUDP reliability
+ run: npm test --workspace packages/cultnet-ts
+
+ - name: Verify Python RUDP reliability
+ run: |
+ python -m unittest discover -s packages/cultcache-py/tests
+ python -m unittest discover -s packages/cultnet-py/tests
+ python -m unittest discover -s packages/cultmesh-py/tests
+
+ - name: Verify Rust RUDP reliability
+ run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp
+
+ - name: Verify Rust CultMesh runtime-local surface
+ run: cargo test --manifest-path packages/cultmesh-rs/Cargo.toml
+
+ - name: Verify C# RUDP reliability
+ run: dotnet test tests/GameCult.Networking.Tests/GameCult.Networking.Tests.csproj --filter "FullyQualifiedName~Rudp"
+
- name: Interop RUDP Python lane
timeout-minutes: 8
run: node --test --test-name-pattern "TypeScript and Python|Python and TypeScript" packages/cultnet-ts/dist-test/test/interop/cultnet-interop.test.js
diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml
new file mode 100644
index 00000000..6061ca7a
--- /dev/null
+++ b/.github/workflows/publish-packages.yml
@@ -0,0 +1,146 @@
+name: Publish packages
+
+# Non-Rust consumers cannot take a package from a subdirectory of a git repo the
+# way cargo can, which is why they ended up vendoring forks instead. This gives
+# them a versioned artifact to depend on so CultLib stays the only source.
+#
+# Rust needs nothing here: cargo git dependencies pin a CultLib revision
+# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
+# consume the core stack.
+#
+# Tag a release to publish, for example `cultcache-ts-v0.13.6` or
+# `cultnet-py-v0.1.0`. The tag's version must match the version in the package
+# manifest; the workflow refuses the publish otherwise rather than shipping a
+# mislabelled artifact.
+
+on:
+ push:
+ tags:
+ - "cultcache-ts-v*"
+ - "cultcache-py-v*"
+ - "cultnet-py-v*"
+ - "cultmesh-py-v*"
+ workflow_dispatch:
+ inputs:
+ package:
+ description: Package to build and test (publishes nothing on manual runs)
+ required: true
+ type: choice
+ options:
+ - cultcache-ts
+ - cultcache-py
+ - cultnet-py
+ - cultmesh-py
+
+permissions:
+ contents: read
+
+jobs:
+ cultcache-ts:
+ name: "@gamecult/cultcache-ts to npm"
+ if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') || inputs.package == 'cultcache-ts'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - name: Check out CultLib
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24.x
+ registry-url: https://registry.npmjs.org
+
+ - name: Install workspace dependencies
+ run: npm ci
+
+ - name: Verify the tag matches the manifest version
+ if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
+ run: |
+ manifest="$(node -p "require('./packages/cultcache-ts/package.json').version")"
+ tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
+ if [ "$manifest" != "$tagged" ]; then
+ echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
+ exit 1
+ fi
+
+ - name: Build and test
+ working-directory: packages/cultcache-ts
+ run: npm test
+
+ # Scoped packages default to restricted even on public npm, and this is
+ # MIT like the rest of the repo, so say public explicitly.
+ - name: Publish
+ if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
+ working-directory: packages/cultcache-ts
+ run: npm publish --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ # The three Python packages layer cultcache-py < cultnet-py < cultmesh-py, the
+ # same way the Rust crates and npm packages do. One job shape serves all
+ # three; the matrix entry names the package and the tag prefix that releases it.
+ python:
+ name: ${{ matrix.package }} to PyPI
+ if: startsWith(github.ref, 'refs/tags/cultcache-py-v') || startsWith(github.ref, 'refs/tags/cultnet-py-v') || startsWith(github.ref, 'refs/tags/cultmesh-py-v') || endsWith(inputs.package, '-py')
+ strategy:
+ fail-fast: false
+ matrix:
+ package: [cultcache-py, cultnet-py, cultmesh-py]
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ environment: pypi
+ permissions:
+ contents: read
+ # PyPI trusted publishing exchanges this for a short-lived token, so no
+ # long-lived API token is stored in the repository.
+ id-token: write
+ steps:
+ - name: Skip packages this run does not name
+ id: selected
+ run: |
+ if [ "${GITHUB_REF_NAME#${{ matrix.package }}-v}" != "$GITHUB_REF_NAME" ] || [ "${{ inputs.package }}" = "${{ matrix.package }}" ]; then
+ echo "run=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "run=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Check out CultLib
+ if: steps.selected.outputs.run == 'true'
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ if: steps.selected.outputs.run == 'true'
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Verify the tag matches the manifest version
+ if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
+ run: |
+ manifest="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('packages/${{ matrix.package }}/pyproject.toml').read_text())['project']['version'])")"
+ tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
+ if [ "$manifest" != "$tagged" ]; then
+ echo "tag $GITHUB_REF_NAME does not match manifest version $manifest" >&2
+ exit 1
+ fi
+
+ # Lower layers install from source so a release of one package is tested
+ # against the sibling revision it was cut with, not whatever PyPI has.
+ - name: Install and test
+ if: steps.selected.outputs.run == 'true'
+ run: |
+ python -m pip install -e packages/cultcache-py -e packages/cultnet-py -e packages/cultmesh-py
+ python -m unittest discover -s packages/${{ matrix.package }}/tests
+
+ - name: Build sdist and wheel
+ if: steps.selected.outputs.run == 'true'
+ run: |
+ python -m pip install --upgrade build
+ python -m build packages/${{ matrix.package }}
+
+ - name: Publish
+ if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
+ uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ packages-dir: packages/${{ matrix.package }}/dist
diff --git a/.gitignore b/.gitignore
index 2fdb5151..e4661501 100644
--- a/.gitignore
+++ b/.gitignore
@@ -206,17 +206,6 @@ PublishScripts/
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
-!/packages/gamecult-geometry-rs/
-!/packages/gamecult-geometry-rs/**
-packages/gamecult-geometry-rs/target/
-.tools/
-!/packages/gamecult-geometry-ts/
-!/packages/gamecult-geometry-ts/**
-!/unity/org.gamecult.geometry/Samples~/
-!/unity/org.gamecult.geometry/Samples~/**
-!/unity/org.gamecult.geometry/Runtime/PlanetaryPatchMeshAdapter.cs.meta
-!/unity/org.gamecult.geometry/Runtime/PlanetaryPageUpload.cs.meta
-!/src/GameCult.Geometry/Shaders/*.hlsl.meta
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
@@ -252,8 +241,6 @@ _pkginfo.txt
ClientBin/
~$*
*~
-!/unity/org.gamecult.geometry/Samples~/
-!/unity/org.gamecult.geometry/Samples~/**
*.dbmdl
*.dbproj.schemaview
*.jfm
@@ -316,10 +303,22 @@ node_modules/
!packages/cultmesh-ts/**
!packages/cultcache-py/
!packages/cultcache-py/**
+!packages/cultnet-py/
+!packages/cultnet-py/**
+!packages/cultmesh-py/
+!packages/cultmesh-py/**
!packages/cultcache-rs/
!packages/cultcache-rs/**
!packages/cultnet-rs/
!packages/cultnet-rs/**
+!packages/cultmesh-rs/
+!packages/cultmesh-rs/**
+!packages/cultmath/
+!packages/cultmath/**
+!packages/cultmesh-browser/
+!packages/cultmesh-browser/**
+!packages/cultmesh-kotlin/
+!packages/cultmesh-kotlin/**
packages/**/node_modules/
packages/**/dist/
packages/**/dist-test/
@@ -517,3 +516,7 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
+
+# Python package build output
+packages/*-py/build/
+*.egg-info/
diff --git a/.voidbot/status/void-memory-maintenance.json b/.voidbot/status/void-memory-maintenance.json
deleted file mode 100644
index 0d0b876a..00000000
--- a/.voidbot/status/void-memory-maintenance.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "status": "starting",
- "startedAt": "2026-06-14T19:46:12.715Z",
- "stateFile": "E:\\Projects\\CultLib\\.voidbot\\state\\libby.cc",
- "launcher": "repo_face_heartbeat",
- "reason": "repo_face_stale_short_term_memory"
-}
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..640c1a14
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+Repository doctrine for this project lives in AGENTS.md, shared with Codex.
+
+@AGENTS.md
diff --git a/CultLib.sln b/CultLib.sln
index 65f940c8..fbc20922 100644
--- a/CultLib.sln
+++ b/CultLib.sln
@@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching", "src\Gam
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack", "src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj", "{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Generator", "src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj", "{0E990C96-3808-450E-9349-A0C3262195ED}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.MessagePack.Analyzers", "src\GameCult.Caching.MessagePack.Analyzers\GameCult.Caching.MessagePack.Analyzers.csproj", "{DBE06954-EC85-4034-9049-72C7D27556DF}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking", "src\GameCult.Networking\GameCult.Networking.csproj", "{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh", "src\GameCult.Mesh\GameCult.Mesh.csproj", "{8D1B01A7-BF9E-44E6-88E1-B17255728B42}"
@@ -29,14 +25,30 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry", "src\Ga
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Tests", "tests\GameCult.Geometry.Tests\GameCult.Geometry.Tests.csproj", "{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Geometry.Planetary.Tests", "tests\GameCult.Geometry.Planetary.Tests\GameCult.Geometry.Planetary.Tests.csproj", "{CE447820-B407-48DE-8957-F373E032C4AE}"
-EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Tests", "tests\GameCult.Mesh.Tests\GameCult.Mesh.Tests.csproj", "{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic", "src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj", "{B67042B8-1135-4E54-B1EB-D45342C720D7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Mesh.Quic.Tests", "tests\GameCult.Mesh.Quic.Tests\GameCult.Mesh.Quic.Tests.csproj", "{21F26329-2119-4037-93CC-DD1801A1AB78}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets", "src\GameCult.Networking.WebSockets\GameCult.Networking.WebSockets.csproj", "{7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSockets.Tests", "tests\GameCult.Networking.WebSockets.Tests\GameCult.Networking.WebSockets.Tests.csproj", "{B9A441EC-DADE-4500-B092-C635F3EB296A}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EveBrowserNetworkSample", "samples\eve-browser-network\EveBrowserNetworkSample.csproj", "{5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "cultmath", "cultmath", "{B48EAE30-152F-01E3-6E31-82B350494D3C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath", "packages\cultmath\src\CultMath\CultMath.csproj", "{54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\cultmath\tests\CultMath.Tests\CultMath.Tests.csproj", "{FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,30 +95,6 @@ Global
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x64.Build.0 = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.ActiveCfg = Release|Any CPU
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB}.Release|x86.Build.0 = Release|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.ActiveCfg = Debug|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x64.Build.0 = Debug|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.ActiveCfg = Debug|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Debug|x86.Build.0 = Debug|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Release|Any CPU.Build.0 = Release|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.ActiveCfg = Release|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x64.Build.0 = Release|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.ActiveCfg = Release|Any CPU
- {0E990C96-3808-450E-9349-A0C3262195ED}.Release|x86.Build.0 = Release|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.ActiveCfg = Debug|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x64.Build.0 = Debug|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.ActiveCfg = Debug|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Debug|x86.Build.0 = Debug|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|Any CPU.Build.0 = Release|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.ActiveCfg = Release|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x64.Build.0 = Release|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.ActiveCfg = Release|Any CPU
- {DBE06954-EC85-4034-9049-72C7D27556DF}.Release|x86.Build.0 = Release|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -179,18 +167,6 @@ Global
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x64.Build.0 = Release|Any CPU
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.ActiveCfg = Release|Any CPU
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31}.Release|x86.Build.0 = Release|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.ActiveCfg = Debug|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x64.Build.0 = Debug|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.ActiveCfg = Debug|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Debug|x86.Build.0 = Debug|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Release|Any CPU.Build.0 = Release|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.ActiveCfg = Release|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x64.Build.0 = Release|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.ActiveCfg = Release|Any CPU
- {CE447820-B407-48DE-8957-F373E032C4AE}.Release|x86.Build.0 = Release|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -227,6 +203,78 @@ Global
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x64.Build.0 = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.ActiveCfg = Release|Any CPU
{21F26329-2119-4037-93CC-DD1801A1AB78}.Release|x86.Build.0 = Release|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x64.Build.0 = Debug|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Debug|x86.Build.0 = Debug|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.ActiveCfg = Release|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x64.Build.0 = Release|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.ActiveCfg = Release|Any CPU
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5}.Release|x86.Build.0 = Release|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x64.Build.0 = Debug|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Debug|x86.Build.0 = Debug|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.ActiveCfg = Release|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x64.Build.0 = Release|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.ActiveCfg = Release|Any CPU
+ {B9A441EC-DADE-4500-B092-C635F3EB296A}.Release|x86.Build.0 = Release|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x64.Build.0 = Debug|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Debug|x86.Build.0 = Debug|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.ActiveCfg = Release|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x64.Build.0 = Release|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.ActiveCfg = Release|Any CPU
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB}.Release|x86.Build.0 = Release|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x64.Build.0 = Debug|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Debug|x86.Build.0 = Debug|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.ActiveCfg = Release|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x64.Build.0 = Release|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.ActiveCfg = Release|Any CPU
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F}.Release|x86.Build.0 = Release|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x64.Build.0 = Debug|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Debug|x86.Build.0 = Debug|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.ActiveCfg = Release|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x64.Build.0 = Release|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.ActiveCfg = Release|Any CPU
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D}.Release|x86.Build.0 = Release|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x64.Build.0 = Debug|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Debug|x86.Build.0 = Debug|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.ActiveCfg = Release|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x64.Build.0 = Release|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.ActiveCfg = Release|Any CPU
+ {D3138F3C-9531-4906-A5D3-74BD857D0685}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -235,17 +283,21 @@ Global
{72AEDD42-E881-4EB7-99A8-FD3B9667189C} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{9DDA917D-B4DB-4AC1-9B9A-4ECF06A5ABA1} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{26EE4C38-F06E-4E7A-A89C-908318BCB0DB} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
- {0E990C96-3808-450E-9349-A0C3262195ED} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
- {DBE06954-EC85-4034-9049-72C7D27556DF} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{67383F6C-C9C3-4C5B-95D4-5C50C20EDD4F} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{8D1B01A7-BF9E-44E6-88E1-B17255728B42} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{E2DC8FF6-D39E-4FCA-9549-1F7681F0AA81} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{EB15435B-7147-4BD9-BB9C-B0A14318D3D5} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{94C086DA-3359-424B-9688-29A198806481} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{C95A9A21-34FA-4D82-BE5B-88FBDA836D31} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
- {CE447820-B407-48DE-8957-F373E032C4AE} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{F8A5060A-FB0B-4B8B-AE06-5147BB4CA127} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{B67042B8-1135-4E54-B1EB-D45342C720D7} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
{21F26329-2119-4037-93CC-DD1801A1AB78} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
+ {7F32D96B-AB5D-4A72-9217-B5DCCFEFC7E5} = {E733CBFF-42AE-4B6A-B5B9-0071ECFB45F1}
+ {B9A441EC-DADE-4500-B092-C635F3EB296A} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
+ {5FA81CC7-87B2-4585-9D8E-9BBEC0AD85FB} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {B48EAE30-152F-01E3-6E31-82B350494D3C} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
+ {54D543F7-8EB7-4A11-977E-22D3EE2D8C3F} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
+ {FEF9DA88-2177-4FD0-885D-7096ACFA8A9D} = {B48EAE30-152F-01E3-6E31-82B350494D3C}
+ {D3138F3C-9531-4906-A5D3-74BD857D0685} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
EndGlobalSection
EndGlobal
diff --git a/CultLib.txt b/CultLib.txt
deleted file mode 100644
index 4755ac07..00000000
--- a/CultLib.txt
+++ /dev/null
@@ -1,132 +0,0 @@
-
-
-// E:\Projects\CultLib\src\GameCult.Caching\GameCult.Caching.csproj
-
-
-
- CultCache: Concurrent polymorphic cache with modular backing stores for .NET
-
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj
-
-
-
- File-based MessagePack backing store for CultCache
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Caching.MessagePack.Generator\GameCult.Caching.MessagePack.Generator.csproj
-
-
-
-
-
- true
- enable
- true
- false
-
-
- Source generator that emits MessagePack formatters for DatabaseEntry subclasses.
- MessagePack;SourceGenerator;DatabaseEntry
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Logging\GameCult.Logging.csproj
-
-
-
- Simple pluggable logging interface for CultLib
-
-
-
-// E:\Projects\CultLib\src\GameCult.Logging.Unity\GameCult.Logging.Unity.csproj
-
-
-
- Unity integration for GameCult.Logging
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\src\GameCult.Networking\GameCult.Networking.csproj
-
-
-
- CultNet: Secure, high-performance messaging for games/servers
-
-
-
-
-
-
-
-
-
-
-
-
-
-// E:\Projects\CultLib\tests\GameCult.Networking.Tests\GameCult.Networking.Tests.csproj
-
-
-
-
- net8.0
- false
- GameCult.Networking.Tests
-
-
-
-
-
-
-
-
-
- runtime; build; native; contentfiles; analyzers; buildtransitive
- all
-
-
-
-
-
-
- all
-
-
-
-
diff --git a/Directory.Build.props b/Directory.Build.props
index a9390c76..10c8d749 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,9 +14,8 @@
MIT
© Meta Vens and contributors
true
- 1.0.33
- $(MSBuildThisFileDirectory)..\CultMath\.tools\local-feed
- $(RestoreAdditionalProjectSources);$(CultMathCandidateFeed)
+ 1.0.35
+ $(CultLibPackageVersion)
true
diff --git a/README.md b/README.md
index 80dc90a5..4977a02a 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,9 @@ The stack is less a single product than a set of capabilities:
- logging primitives and implementations
- CultCache typed documents, `.cc` persistence, and SoA memory access
-- CultNet schema-v0 messages over TCP, WebSocket, LiteNetLib adapters, and
- CultLib's native cross-runtime RUDP pipe
+- CultNet schema-v0 messages over TCP and LiteNetLib adapters, a bounded
+ ASP.NET Core WebSocket host adapter, and CultLib's native cross-runtime RUDP
+ pipe
- CultMesh distributed realtime database, Verse discovery, authority leases,
shard replication, and witness-authoritative simulation consensus
- typed geometry domain/chunk documents for distributed CSG and LOD streaming
@@ -37,8 +38,9 @@ and pretending the label makes it furniture.
| Job | Start With | Owns | Use When | Do Not Use It For |
| --- | --- | --- | --- | --- |
| Local typed state | `GameCult.Caching` / CultCache | Document identity, schema compatibility, record keys, indexes, globals, and local persistence | You need a typed cache, file-compatible save data, local reactive reads, or a stable domain document model | Peer discovery, transport security, shard routing, or mesh consensus |
-| Procedural geometry state | `GameCult.Geometry` | CultCache-native domain trees, LOD build requests, selected-cut diagnostics, and chunk artifact payloads | Rust, Unity, or remote workers need to share CSG/LOD geometry and graph metadata as typed state | Transport policy, peer discovery, or gameplay authority |
-| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP/WebSocket adapters, authentication, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition |
+| Portable math | `GameCult.Math` / CultMath | Shader-shaped numeric primitives, deterministic random, noise, and CPU/GPU parity helpers | Shared simulation and geometry code must not depend on an engine's math or RNG types | Domain-specific geometry, rendering, or world authority |
+| Procedural geometry | `GameCult.Geometry` / CultGeometry | Isosurface extraction plus CultCache-native domains, LOD requests, selected cuts, and mesh artifacts | C#, Rust, Godot, or remote workers need shared geometry algorithms and typed results | Transport policy, peer discovery, or gameplay authority |
+| Network transport and database plumbing | `GameCult.Networking` / CultNet | Native RUDP sessions, LiteNetLib/TCP adapters, an authenticated WebSocket host adapter, schema-v0 wire contracts, shard authority, raw document replication, snapshots, and subscriptions | You need a client/server pipe, login/session flow, schema discovery, reliable UDP, or a low-level distributed CultCache lane | Gameplay-facing mesh ergonomics, Verse policy, mod branches, or simulation consensus composition |
| Distributed realtime gameplay state | `GameCult.Mesh` / CultMesh | Public mesh entrypoints, Verse discovery, peer exchange, shard replication defaults, authority leases, client prediction, and witness consensus | You want the game to treat clients and servers as one reactive database for persistent state, input state, and simulation facts | A tiny local-only tool, a bare transport client, or a storage format contract |
| Realtime media/frame streams | `GameCult.Mesh` / CultMesh streaming mode | Stream identity, authority, clock metadata, body transport negotiation, frame cursors, and backpressure state | Audio/video/tensor frames need to move between runtimes through shared memory, GPU handles, platform buffers, or CultCache page refs | Durable document mutation, mesh consensus facts, or pretending inline bytes are zero-copy |
| Daemon-published UI | Eve / CultUI | Typed operator and user-facing surfaces over CultMesh state | A daemon should expose controls, inspection, or workflow UI that can lower into GUI, TUI, web, native, overlay, or agent clients | One-off vendor dashboards, untyped debug panels, or runtime-local UI that cannot travel with the daemon |
@@ -46,8 +48,10 @@ and pretending the label makes it furniture.
Quick rule:
- Choose CultCache when the problem is "how do I model and persist typed state?"
-- Choose GameCult.Geometry when the problem is "how do geometry workers share
- domain trees, LOD build requests, and mesh chunks as typed state?"
+- Choose GameCult.Math when the problem is "which portable numeric and random
+ semantics do these runtimes share?"
+- Choose GameCult.Geometry when the problem is "how do geometry workers build
+ and share engine-neutral geometry?"
- Choose CultNet when the problem is "how do peers exchange authenticated,
schema-aware database messages?"
- Choose CultMesh when the problem is "how does a game join a Verse and share
@@ -62,8 +66,25 @@ the stack. If a design needs another peer-to-peer category, first check whether
it is really Verse policy, peer exchange, or shard authority wearing a fake
mustache.
-If you want the smallest durable "open node -> put typed doc -> get typed doc"
-path, start with
+If you want to build an application rather than tour the component drawer,
+start with the maintained
+[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md)
+series. Its first executable package-consumer checkpoint is
+[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
+The real-process browser continuation is
+[`samples/eve-browser-network`](samples/eve-browser-network/README.md).
+One documented command runs both layers:
+
+```powershell
+pwsh -File ./scripts/verify-eve-getting-started.ps1
+```
+
+The implemented browser client boundary and remaining deployed-infrastructure
+gates are mapped explicitly in
+[`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md).
+
+If you want only the smallest durable "open node -> put typed doc -> get typed doc"
+path, use
[`src/GameCult.Mesh/docs/durable-node-quickstart.md`](src/GameCult.Mesh/docs/durable-node-quickstart.md).
If you want the lower-level "typed local document -> raw wire document ->
mesh/runtime watch surface" handoff, follow it with
@@ -77,10 +98,10 @@ The solution includes:
- `GameCult.Caching`: `DatabaseEntry`-based cache, indexes, global entries, and backing-store abstractions
- `GameCult.Caching.MessagePack`: MessagePack-backed persistence for the cache
- `GameCult.Caching.NewtonsoftJson`: Newtonsoft.Json-backed persistence for the cache
-- `GameCult.Caching.MessagePack.Generator`: source generator for MessagePack formatters for cache models
-- `GameCult.Caching.MessagePack.Analyzers`: packaging project that delivers the generator to consuming projects
-- `GameCult.Geometry`: geometry, planetary mapping, CSG, LOD selection, mesh assembly, and CultCache-native geometry documents built on CultMath primitives
+- `GameCult.Math` / CultMath: portable numeric primitives, deterministic random, noise, and shader-parity helpers
+- `GameCult.Geometry` / CultGeometry: engine-neutral geometry algorithms plus CultCache-native domain, selected-cut, and mesh artifact documents
- `GameCult.Networking`: encrypted login/register/verify flows, schema-v0 contracts, transport adapters, and native RUDP sessions
+- `GameCult.Networking.WebSockets`: bounded ASP.NET Core binary WebSocket host adapter for browser CultMesh clients
- `GameCult.Mesh`: CultMesh package home for distributed realtime database, shard replication, client prediction, Verse discovery, and mesh witness consensus
- `GameCult.Caching.Tests`: NUnit tests for cache and backing-store behavior
- `GameCult.Networking.Tests`: NUnit tests for networking behavior
@@ -88,7 +109,10 @@ The solution includes:
- `packages/cultcache-ts`: TypeScript CultCache with MessagePack persistence and inspector tooling
- `packages/cultnet-ts`: TypeScript CultNet schema-v0 contracts, framing, discovery, raw document replication, and interop tests
- `packages/cultmesh-ts`: TypeScript CultMesh local node and mesh catalog surface for local runtimes such as VoidBot
-- `packages/cultcache-py`: Python CultCache/CultNet/CultMesh package with CultCache v1 wire parity
+- `packages/cultmesh-browser`: browser-safe, read-only CultMesh document leases and typed operations over an Odin-resolved WebSocket route
+- `packages/cultcache-py`: Python CultCache with CultCache v1 wire parity
+- `packages/cultnet-py`: Python CultNet contracts, framing, RUDP transport, replication, and interop peer
+- `packages/cultmesh-py`: Python CultMesh local node, discovery, sessions, local server, and daemon
- `packages/cultcache-rs`: Rust CultCache and derive macro
- `packages/cultnet-rs`: Rust CultNet contracts, framing, discovery, and interop peer
- `packages/cultmesh-kotlin`: Kotlin/JVM CultMesh and CultNet surface for Android-adjacent runtimes
@@ -102,10 +126,9 @@ src/
GameCult.Caching/
GameCult.Caching.MessagePack/
GameCult.Caching.NewtonsoftJson/
- GameCult.Caching.MessagePack.Generator/
- GameCult.Caching.MessagePack.Analyzers/
GameCult.Geometry/
GameCult.Networking/
+ GameCult.Networking.WebSockets/
GameCult.Mesh/
GameCult.Unity/
tests/
@@ -113,11 +136,15 @@ tests/
GameCult.Geometry.Tests/
GameCult.Networking.Tests/
packages/
+ cultmath/
cultcache-ts/
cultnet-ts/
cultmesh-ts/
+ cultmesh-browser/
cultmesh-kotlin/
cultcache-py/
+ cultnet-py/
+ cultmesh-py/
cultcache-rs/
cultnet-rs/
```
@@ -141,8 +168,22 @@ npm test --workspace packages/cultcache-ts
npm test --workspace packages/cultnet-ts
npm run test:interop --workspace packages/cultnet-ts
npm test --workspace packages/cultmesh-ts
+npm test --workspace packages/cultmesh-browser
```
+Measured reactive, retained-session, and shared-memory hot-body gates:
+
+```powershell
+pwsh -File ./scripts/measure-reactive-document-performance.ps1 -Quick
+pwsh -File ./scripts/verify-eve-getting-started.ps1
+```
+
+The first command measures the same 16 KiB document workload in C#,
+TypeScript, and Python and includes a mapped hot-body probe with explicit copy
+telemetry on Windows named maps and Unix file-backed maps. The second includes
+10,000 real WebSocket operations on one retained
+identity-owned C# session after provider route replacement.
+
Rust package tests:
```powershell
@@ -192,41 +233,23 @@ public class ItemData : DatabaseEntry, INamedEntry
- backing stores are persistence adapters
- indexes and name maps are maintained inside the cache, not inside the store
-### Important: Multiple Backing Stores
+### Multiple Backing Stores
-When multiple backing stores are added, behavior depends on how they are registered.
-
-If a store is added with domain types:
+Every document type has exactly one home store. There are no mirrors.
```csharp
-cache.AddBackingStore(playerStore, typeof(PlayerData));
-cache.AddBackingStore(settingsStore, typeof(AppSettings));
+cache.AddBackingStore(catalogStore, typeof(ItemData), typeof(Faction));
+cache.AddBackingStore(runStore, typeof(SavedGame));
```
-then that store becomes the direct persistence target for those types.
+- the home is chosen by the document's runtime type: the most derived routed type wins, otherwise the untyped store
+- at most one untyped store; a type claimed by two stores is an error
+- a write with no home is an error once any store is attached
+- attaching a store reads it; attach routed stores first and the untyped store (if any) last, because a record's home cannot move after it is loaded
+- loading never writes: opening and flushing without a mutation leaves every file byte-identical
+- a read-only store refuses writes and is skipped by flush
-If a store is added without domain types:
-
-```csharp
-cache.AddBackingStore(primaryStore);
-cache.AddBackingStore(mirrorStore);
-```
-
-then the first generic store acts as the primary writable store for non-domain-specific entries. Additional generic stores subscribe to the existing stores and mirror their change events.
-
-Implications:
-
-- order matters for generic stores
-- `AddAsync` writes to the type-specific store when one exists
-- otherwise `AddAsync` writes to the first generic store
-- later generic stores do not become co-primaries; they mirror earlier stores
-- `PullAllBackingStoresAsync` pulls from every registered store
-
-Recommended patterns:
-
-- use one generic primary store if you want simple persistence
-- use domain-specific stores when different entry types belong in different persistence layers
-- treat additional generic stores as mirrors or downstream replicas, not independent write targets
+The contract is `src/GameCult.Caching/Contracts/cultcache-store-composition.md`. Until the C# cache implements it, `AddBackingStore` takes no home types and every attached store receives every record; attach one store per cache.
## Example: Cache + Networking
@@ -322,8 +345,6 @@ Each subproject has a local README with package-specific detail:
- [GameCult.Caching](src/GameCult.Caching/README.md)
- [GameCult.Caching.MessagePack](src/GameCult.Caching.MessagePack/README.md)
- [GameCult.Caching.NewtonsoftJson](src/GameCult.Caching.NewtonsoftJson/README.md)
-- [GameCult.Caching.MessagePack.Generator](src/GameCult.Caching.MessagePack.Generator/README.md)
-- [GameCult.Caching.MessagePack.Analyzers](src/GameCult.Caching.MessagePack.Analyzers/README.md)
- [GameCult.Geometry](src/GameCult.Geometry/README.md)
- [GameCult.Networking](src/GameCult.Networking/README.md)
- [GameCult.Mesh](src/GameCult.Mesh/README.md)
diff --git a/docs/cultmath-gamecult-geometry-migration-plan.md b/docs/cultmath-gamecult-geometry-migration-plan.md
deleted file mode 100644
index e8e961c4..00000000
--- a/docs/cultmath-gamecult-geometry-migration-plan.md
+++ /dev/null
@@ -1,552 +0,0 @@
-# CultMath and GameCult.Geometry Staged Migration Plan
-
-Status: proposed for execution
-
-Date: 2026-07-22
-
-## Decision
-
-`GameCult.Geometry` is the sole owner of geometric meaning and geometry
-machinery. It builds on CultMath numeric primitives and participates in
-CultCache, CultNet, and CultMesh through the normal CultLib integration paths.
-
-CultMath owns the HLSL-shaped numeric substrate: scalar operations, vectors,
-quaternions, and general numeric kernels that have no spatial domain authority.
-It does not own regions, topology, projections, surfaces, planetary fields,
-patches, geometry residency, meshes, or geometry workflow state.
-
-VibeGeometry and `vg-csg` are sources to mine. They are not surviving owners or
-dependencies.
-
-Source transfer does not erase provenance. CultMath is MPL-2.0 and CultLib is
-MIT-licensed; moved files require an explicit licensing decision and preserved
-notices before they enter a differently licensed package.
-
-## Objective
-
-Produce one coherent geometry stack in which:
-
-- consumers use `CultMath.float2`, `float3`, and `float4` as the canonical
- floating-point vector primitives;
-- `GameCult.Geometry` owns reusable geometry, planetary geometry, CSG, mesh
- production, spatial queries, and the typed documents that coordinate those
- systems;
-- CultMath no longer publishes planetary or other geometry-owned APIs;
-- `CultVec2` and `CultVec3` no longer exist;
-- VibeGeometry can be archived after its useful implementation, evidence, and
- doctrine have been absorbed;
-- persisted geometry remains readable through an explicit migration path when
- a live persisted corpus requires one.
-
-## Authority Map
-
-| Organ | Owns | Does not own |
-| --- | --- | --- |
-| CultMath | `float2`, `float3`, `float4`, integer/double/bool vector primitives, quaternion algebra, scalar and component-wise intrinsics, coordinate-agnostic numeric kernels, CPU/HLSL parity for those primitives | Rectangles, spheres, topology, projections, planetary fields, geometry pages, meshes, CSG, persistence schemas, worker commands |
-| GameCult.Geometry | Spatial value types built from CultMath primitives, spatial queries, topology and projection, planetary field and page machinery, mesh and CSG implementation, geometry schema documents, stable geometry identities, geometry-specific HLSL and cross-runtime fixtures | Competing vector types, generic storage/network ownership, application gameplay consequences |
-| CultCache/CultNet/CultMesh | Typed persistence, schema resolution, transport, discovery, publication, commands, and interface projection | Geometry semantics or geometry algorithm ownership |
-| VibeGeometry / `vg-csg` | Nothing after migration | Any live runtime, schema, package, or compatibility authority |
-
-Authority sentence:
-
-> CultMath owns how numeric primitives behave; GameCult.Geometry owns what those
-> primitives mean in space.
-
-## Current Mechanism
-
-The live body is split across three places:
-
-1. CultMath publishes the canonical HLSL-shaped vector types, but also contains
- `rect`, planetary topology/projection/field/page/residency systems, spherical
- erosion, planetary HLSL, Unity adapters, web fixtures, and planetary tools.
-2. `GameCult.Geometry` contains duplicate `CultVec2` and `CultVec3` values,
- geometric primitives, and four CultCache document roots. Its document
- members still encode many vectors, bounds, transforms, vertices, normals,
- and UVs as anonymous `float[]` values.
-3. VibeGeometry wraps the `vg-csg` Rust implementation for domain-tree intent,
- LOD-aware selected cuts, CSG lowering, and triangle/collider output.
-
-CultCache's current managed SoA projection only admits primitive scalar members.
-It neither preserves arbitrary unmanaged structs as columns nor recursively
-splits them. A `float2` or `float3` member is therefore omitted from the SoA
-until that storage seam is repaired.
-
-## Invariants
-
-The migration must preserve these statements at every published boundary:
-
-1. There is one canonical vector vocabulary: CultMath's HLSL-shaped primitive
- types.
-2. Geometry algorithms and geometry state have one owner: `GameCult.Geometry`.
-3. CultMath never depends on CultLib, CultCache, MessagePack, or
- `GameCult.Geometry`.
-4. `GameCult.Geometry` may depend on CultMath and CultLib.
-5. Persistence decoration and formatters do not contaminate CultMath primitive
- definitions.
-6. A geometry schema has one active writer. Legacy readers or offline
- migrators may exist temporarily, but they cannot decide live behavior.
-7. Record keys and stable fingerprints are identical across supported runtimes
- for the same semantic geometry value.
-8. SoA storage preserves each authored unmanaged member as one column of its
- exact type. It never decomposes `float2`, `float3`, quaternion, or another
- value type by reflection.
-9. CPU, HLSL, Unity, web, and Rust fixtures compare the same claimed layer of
- geometry behavior.
-10. VibeGeometry is not retained as a wrapper, submodule, compatibility crate,
- or second source of truth.
-
-## Target Package Shape
-
-The exact directory names may follow existing CultLib conventions, but the
-ownership should lower into these runtime surfaces:
-
-```text
-CultMath
- C# and Unity numeric primitives
- CultMath.hlsl numeric mirror
- coordinate-agnostic native numeric kernels
-
-CultLib
- src/GameCult.Geometry/
- C# geometry API
- planetary geometry
- CultCache document contracts and formatters
- geometry-specific HLSL packaged as content
- packages/gamecult-geometry-rs/
- Rust geometry, CSG, and cross-runtime fixtures
- packages/gamecult-geometry-ts/ # only if the web runtime remains earned
- web projection/tile helpers and parity fixtures
- src/GameCult.Unity/Assets/Geometry/ # Unity-owned lowering/adapters
- mesh/page upload and presentation adapters
- Packages/org.gamecult.geometry/ # final location follows packaging audit
- Unity package depending on CultMath and CultLib
-```
-
-The runtime directories are projections of one geometry authority. They are not
-independent products allowed to invent their own vector, topology, schema, or
-fingerprint semantics.
-
-Each stage is a commit/review boundary. Do not begin the next stage until the
-current gate passes, and never publish a state in which both repositories claim
-the same geometry authority.
-
-## Source Classification
-
-### Keep in CultMath
-
-- HLSL-shaped primitive types such as `float2`, `float3`, and `float4`.
-- Integer, double, and boolean vector primitives.
-- Quaternion algebra.
-- `math` scalar/component-wise operations and the core `CultMath.hlsl` mirror.
-- Numeric batch kernels whose contracts are independent of regions, topology,
- surfaces, planets, meshes, CSG, rendering policy, and persistence.
-
-### Move from CultMath to GameCult.Geometry
-
-The initial move set is:
-
-- `rect` and other spatial-region values;
-- `PlanetaryTopology`;
-- `PlanetaryProjection`;
-- `PlanetaryQueries`;
-- `PlanetaryPatch`;
-- `PlanetaryPages`;
-- `PlanetaryGpuPages`;
-- `PlanetaryResidency`;
-- `PlanetaryRadialRefinement`;
-- `PlanetaryMapTileEncoding`;
-- `PlanetaryField`;
-- `AdvancedErosionFilter`;
-- `ErosionFrequencyBands`;
-- `SphericalErosion`;
-- `AdvancedErosionFilter.hlsl`;
-- `Planetary.hlsl`;
-- spherical-erosion and planetary-radial-refinement shader bodies currently
- embedded in `CultMath.hlsl`;
-- the versioned `CMPT` planetary tile format and its TypeScript decoder;
-- planetary tests, docs, tools, Unity adapters, web fixtures, and samples.
-
-`AdvancedErosionFilter` and `ErosionFrequencyBands` move with planetary
-geometry. Their public contracts encode terrain-field policy, they are direct
-dependencies of the planetary field definition, and their HLSL is coupled to
-that field. Voronoi tone rendering and `native/cultmath-core` are outside this
-migration: the native crate currently owns only that tone kernel and has no
-planetary implementation. Audit those separately rather than dragging them
-across this boundary because their names look spatial.
-
-### Rebuild inside GameCult.Geometry
-
-- Make `CultRect`, `CultCircle`, and `CultSphere` the GameCult.Geometry-owned
- spatial values, implemented over CultMath primitives, and delete
- `CultMath.rect` at cutover.
-- Replace `CultVec2` and `CultVec3` with `float2` and `float3` at every typed
- boundary.
-- Replace vector-shaped `float[]` document members with typed CultMath values or
- intentional packed buffers.
-- Keep packed mesh buffers packed when they are genuinely bulk artifact storage;
- do not turn every vertex buffer into a forest of object wrappers.
-- Put MessagePack/CultCache formatters for CultMath values in the integration
- package or generated formatter output, not on the CultMath types.
-
-### Mine from VibeGeometry and `vg-csg`
-
-Evaluate and extract, in dependency order:
-
-- convex plane splitting and brush operations;
-- CSG tree and ordered operation semantics;
-- domain-tree DSL and lowering;
-- dirty-frontier and prefix-checkpoint work;
-- selected-cut and LOD planning;
-- triangle/collider assembly;
-- performance fixtures and parity evidence;
-- useful RealtimeCSG-derived behavioral fixtures;
-- runtime doctrine that still describes the adopted machine.
-
-Rewrite extracted code under GameCult.Geometry ownership. Preserve third-party
-license notices and source provenance. Do not retain a `vg-csg` forwarding
-crate or a VibeGeometry submodule after consumers have moved.
-
-## Staged Migration
-
-### Stage 0: Freeze and Baseline
-
-Purpose: establish the evidence needed to move authority without guessing.
-
-Work:
-
-- Freeze new planetary and geometry API additions in CultMath, VibeGeometry,
- and the current duplicate primitive surface.
-- Do not publish an intermediate release containing duplicate live planetary
- implementations. A coordinated working branch may hold source temporarily
- for comparison, but only one owner may enter a package feed.
-- Inventory public symbols, package versions, HLSL entrypoints, Unity samples,
- web fixtures, native exports, persisted schema IDs, record-key algorithms,
- and known consumers.
-- Inventory exact contract constants: `FieldVersion`, enum ordinals, tile stable
- keys, `CMPT` versions and bytes, MessagePack slots, GPU buffer layouts,
- integer hashes, topology face/seam/corner rules, and shader include paths.
-- Search deployed and checked-in `.cc` stores for the four
- `gamecult.geometry.*.v1` schemas.
-- Record whether any external consumer relies on CultMath planetary namespaces,
- `CultVec*`, `CultMath.rect`, `vg-csg`, or the current HLSL function names.
-- Capture passing baselines for CultMath tests, GameCult.Geometry tests,
- `vg-csg` tests/clippy, cross-runtime planetary fixtures, and package builds.
-- Add representative fixture hashes for projection, topology, field sampling,
- patch selection, page encoding, CSG output, and geometry record keys.
-- Resolve licensing and provenance for MPL-2.0 CultMath sources and any
- third-party-derived `vg-csg` behavior before copying source into CultLib.
-
-Gate:
-
-- Every published surface has a named consumer or is marked for deletion.
-- The persisted v1 corpus is known rather than assumed.
-- Baseline fixtures can be run without booting an entire daemon.
-
-### Stage 1: Establish the Destination and Dependency Direction
-
-Purpose: make `GameCult.Geometry` capable of receiving geometry without growing
-a second primitive vocabulary.
-
-Work:
-
-- Add the explicit `GameCult.Geometry -> CultMath` dependency.
-- Define the target namespaces and package content layout for C#, Rust, HLSL,
- Unity, and any retained web surface.
-- Establish distribution before source transfer: an explicit NuGet dependency
- and a dedicated `org.gamecult.geometry` Unity package depending on
- `org.gamecult.cultmath` and `org.gamecult.cultlib`, unless package inspection
- proves an existing surface already owns that role.
-- Retain `net8.0` and `netstandard2.1` targets and Unity 2021.3 compatibility.
- The geometry asmdef references CultMath; the Unity lowering assembly
- references both. CultMath's asmdef remains reference-free with
- `noEngineReferences: true`.
-- Add GameCult.Geometry-owned MessagePack formatters for `float2`, `float3`,
- `float4`, and quaternion with fixed positional layouts.
-- Add cross-runtime byte fixtures for those value encodings.
-- Repair CultCache's SoA member discovery so any direct unmanaged member is one
- exact typed column. Add `Column` and `Column` tests and a
- negative test proving no scalar decomposition occurs.
-- Ensure generated schema metadata describes the authored vector member rather
- than synthetic x/y/z members.
-
-Gate:
-
-- CultMath has no new dependency.
-- `GameCult.Geometry` can serialize, deserialize, index, and expose intact
- CultMath vectors.
-- CultCache hot-path access returns intact vector spans.
-- Built NuGet and UPM artifacts contain the expected dependency metadata,
- formatters, shaders, and owned runtime assets.
-
-### Stage 2: Replace Duplicate Primitives and Repair Geometry Schemas
-
-Purpose: remove the duplicate vocabulary before moving larger geometry systems.
-
-Work:
-
-- Rewrite `CultRect`, `CultCircle`, and `CultSphere` with CultMath primitives.
-- Delete `CultVec2` and `CultVec3`; do not add aliases or conversion wrappers.
-- Replace typed references and opaque native-slice metadata using the obsolete
- names.
-- Classify every vector-shaped `float[]` in `CultGeometryDocuments.cs`:
- - `Translation`, `SupportCenter`, `SupportSize`, `CameraPosition`,
- `FrustumMin`, `FrustumMax`, `BoundsMin`, and `BoundsMax` become `float3`;
- - `RotationXyzw` becomes quaternion;
- - mesh `Positions` and `Normals` become `float3[]`;
- - mesh `Uvs` becomes `float2[]`;
- - scalar and index buffers remain packed arrays;
- - use a named geometry value when bounds/topology carries an invariant.
-- Define v2 schemas for every document whose field types change. CultCache
- includes CLR type identity in the canonical schema and must reject an in-place
- slot type mutation even when no durable v1 corpus is found.
-- If live v1 documents exist, add bounded v1 reader DTOs and an offline
- v1-to-v2 migrator. Stop all v1 writers at cutover. If no live persisted corpus
- exists, omit the migrator and delete v1 runtime support, but still publish the
- corrected contract under v2 identity.
-- Reject malformed legacy vectors: fixed vectors must have the exact expected
- component count, while packed mesh streams must be divisible by their element
- width before conversion.
-- Recompute and cross-check stable fingerprints and record keys against Rust
- fixtures before accepting the new schemas. Preserve an old record key only
- when the typed representation reproduces the prior IEEE-bit canonicalization
- exactly; otherwise version the identity deliberately.
-- Use CultMath's lowercase component names in v2 JSON inspection projections.
- JSON is not the runtime transport, and uppercase `X/Y/Z` aliases must not keep
- the deleted vector vocabulary alive. Any xenos-facing alternate casing is an
- explicit boundary projection.
-
-Gate:
-
-- Repository-wide searches find no `CultVec2` or `CultVec3` outside historical
- evidence.
-- Repository and package searches find no `CultMath.rect`.
-- No live writer emits vector-shaped anonymous arrays for semantic single
- values.
-- The old schema cannot overwrite, repair, or become a second owner of v2
- state.
-
-### Stage 3: Transfer Planetary Geometry out of CultMath
-
-Purpose: move planetary authority as one coherent vertical slice.
-
-Work:
-
-- Move the classified planetary C# implementation and tests into
- `GameCult.Geometry`.
-- Move `Planetary.hlsl` and rename its include ownership and public entrypoints
- to GameCult.Geometry. It may include CultMath numeric HLSL; CultMath must no
- longer include planetary geometry. Remove the current implicit planetary
- include from `CultMath.hlsl` only after consumers include the new owner.
-- Move `AdvancedErosionFilter.hlsl` plus the spherical-erosion and
- planetary-radial-refinement bodies embedded in `CultMath.hlsl`. Publish one
- geometry-owned umbrella include that imports CultMath first and geometry
- organs afterward.
-- Move planetary documentation, fixture tools, web tile code, Unity page/mesh
- adapters, and the planetary viewer sample to their consumer-owned runtime
- locations.
-- Move ownership of the `CMPT` little-endian tile format and TypeScript decoder.
- Preserve v1 bytes exactly or introduce a new format version; never reuse a
- wire version after changing its bytes.
-- Rename public namespaces and symbols to their GameCult.Geometry authority.
- Prefer a coordinated breaking release over permanent forwarding types.
-- Move spherical/terrain erosion components classified as geometry. Keep only
- genuinely coordinate-agnostic numeric kernels in CultMath.
-- Run CPU/HLSL and cross-runtime fixtures from the new owner before deleting
- the old files.
-- Preserve exact fixtures for field versions, enum ordinals, integer hashes,
- tile keys/bytes, GPU buffer packing, MessagePack slots, topology seams and
- corners, and record hashes. Use explicit tolerances only for floating
- CPU/HLSL comparisons.
-- Delete the corresponding CultMath sources, package entries, and implicit
- includes in the same authority-transfer stage after the destination passes.
- Do not defer the actual ownership cut to consumer cleanup.
-
-Gate:
-
-- GameCult.Geometry passes the captured planetary baseline and owns the fixture
- commands.
-- CultMath packages no `Planetary.hlsl`, planetary sample, planetary tool, or
- planetary public namespace.
-- No source or shader include can select an old CultMath planetary path.
-- Package inspection finds no planetary API, Unity adapter, sample, tile
- decoder, documentation claim, or implicit shader include in CultMath.
-
-### Stage 4: Mine and Absorb `vg-csg`
-
-Purpose: preserve useful CSG machinery without preserving the defunct owner.
-
-Work:
-
-- Import the earned Rust implementation into the GameCult.Geometry runtime
- surface with source provenance and applicable license notices.
-- Replace `bevy_math`-shaped public contracts where they conflict with the
- canonical cross-runtime primitive/layout contract. Internal use may remain
- where it protects a measured invariant and does not leak a second public
- vocabulary.
-- Connect domain trees, selected cuts, build requests, and emitted artifacts to
- the GameCult.Geometry schema definitions.
-- Port useful fixtures before refactoring algorithms so observable behavior has
- a witness.
-- Preserve the existing Rust v1 tuple MessagePack and stable-hash fixtures as
- migration witnesses, then make the GameCult.Geometry-owned Rust package emit
- the v2 contract.
-- Re-run performance fixtures and retain thresholds only where the fixture
- represents a real workload.
-- Move surviving doctrine into GameCult.Geometry docs and discard historical
- instructions that would recreate VibeGeometry ownership.
-
-Gate:
-
-- No production build or test depends on the VibeGeometry repository or
- `vg-csg` submodule.
-- CSG output satisfies the adopted correctness and performance fixtures from
- its new package.
-- The Rust runtime speaks the same schema IDs, vector layouts, fingerprints,
- and record keys as C#.
-
-### Stage 5: Integrate Geometry Through CultCache, CultNet, and CultMesh
-
-Purpose: prove the new owner through the real typed-state path.
-
-Work:
-
-- Register the adopted geometry schemas and formatters through the generated
- registry path.
-- Prove raw envelope interchange for geometry documents across C# and Rust,
- adding TypeScript only if an actual web consumer remains.
-- Publish geometry worker state and commands through the provider-owned
- CultMesh surface.
-- Ensure geometry build requests, selected cuts, chunk artifacts, and planetary
- page state share one commit/derivation path for direct and programmatic use.
-- Add a development probe exposing schema version, owner, source record key,
- selected cut/page, content hash, and served package version.
-
-Gate:
-
-- A pipeline smoke proves typed handoff from domain input through selected cut
- or planetary page selection to a persisted and republished artifact.
-- The visible/debug projection and persisted state identify the same owner and
- version.
-- No ad hoc JSON or parallel status model becomes load-bearing geometry truth.
-
-### Stage 6: Consumer Cutover and Coordinated Release
-
-Purpose: switch consumers without maintaining two live authorities.
-
-Release order:
-
-1. Build an unpublished CultMath candidate that contains the stable primitive
- contract and removes geometry-owned APIs.
-2. Build an unpublished GameCult.Geometry candidate against that exact CultMath
- version, containing adopted schemas, planetary geometry, and runtime packages.
-3. Update and test GameCult services, Unity projects, web consumers, and tools
- against a local candidate feed.
-4. Publish the CultMath and GameCult.Geometry candidates in one coordinated
- release window, with the primitives-only CultMath dependency entering the
- feed first.
-5. Publish consumer updates after both owning packages are available.
-6. Convert persisted v1 geometry stores, if any, and remove migration tooling
- after the retained-corpus policy is satisfied.
-
-Work:
-
-- Pin compatible package versions across the coordinated release.
-- Update examples and developer-facing entrypoints to use
- `GameCult.Geometry` plus CultMath primitives directly.
-- Cut Fensalir's Engine/Contracts public planetary types and shader includes to
- the new assembly before removing CultMath geometry. Its CPU/GPU parity tests
- are release gates.
-- Add the geometry package to Aetheria/Unity, move its directly compiled
- planetary sources to the new assembly, preserve moved Unity `.meta` GUIDs,
- and regenerate Unity project files.
-- Verify served package, shader, schema, and migration versions in at least one
- real consumer for each retained runtime.
-- Run the preserved parity corpus through known consumers including Fensalir,
- Zyphos, Aetheria/Unity, the web tile decoder when retained, C#, Rust, and HLSL.
-- Refuse mixed old/new geometry writers rather than reconciling them later.
-
-Gate:
-
-- Every known consumer runs on the new owner.
-- No published package graph has `CultMath -> CultLib` or
- `CultMath -> GameCult.Geometry`.
-- Old and new writers cannot target the same state surface.
-
-### Stage 7: Delete Obsolete Authorities
-
-Purpose: make the old ownership structurally unable to return.
-
-Work:
-
-- Confirm deletion of all CultMath planetary/geometry sources, tests, shaders,
- samples, docs, tools, web surfaces, package content entries, and Unity
- adapters; remove any residue found after consumer cutover.
-- Delete obsolete GameCult.Geometry duplicate primitive code and stale schema
- paths.
-- Delete VibeGeometry workspace wiring and the `vg-csg` submodule dependency;
- archive the repositories after provenance links and release tags are
- recorded.
-- Remove compatibility aliases, forwarding namespaces, dual registries, and
- temporary migration flags.
-- Update source indexes and repository memory so future agents retrieve the new
- authority first.
-
-Gate:
-
-- Negative searches and package inspection prove the old paths are absent.
-- Building CultMath alone cannot access planetary or geometry APIs.
-- Building GameCult.Geometry cannot access `CultVec*` or VibeGeometry.
-- The old persisted schema path cannot write or override current geometry.
-
-## Schema Migration Policy
-
-Do not create a permanent compatibility model pre-emptively.
-
-- If no durable v1 corpus exists, change the schema once, update fixtures, and
- publish the corrected contract as v2, then delete the old form without
- building a migrator.
-- If a durable v1 corpus exists, create an offline migrator with explicit input
- and output schema IDs, content hashes, counts, and a dry-run report.
-- A v1 reader may exist only inside migration/inspection tooling.
-- The runtime writes only the current schema after cutover.
-- Migration completes only when retained stores have receipts and no active
- service advertises or writes v1.
-
-## Verification Matrix
-
-| Layer | Required evidence |
-| --- | --- |
-| CultMath primitives | C#/HLSL parity, unmanaged layout/size checks, package inspection |
-| Geometry values | Exact `float2`/`float3` field ownership, intersection/query unit tests, MessagePack byte fixtures |
-| CultCache SoA | Intact vector columns, no recursive flattening, add/update/remove/snapshot behavior |
-| Geometry schemas | Catalog IDs and slots, v1 inventory or migration receipts, stable record-key parity |
-| Planetary geometry | Projection round trips, topology adjacency, field/normal samples, radial refinement, page encoding/residency, CPU/HLSL parity |
-| CSG | Boolean/convex fixtures, dirty rebuild behavior, selected-cut correctness, triangle/collider receipts, performance fixtures |
-| Cross-runtime | C#/Rust payload and fingerprint parity; web parity only for retained web consumers |
-| Packaging | CultMath contains no geometry assets; GameCult.Geometry includes its owned HLSL/docs/runtime assets |
-| End to end | Domain or planetary input to persisted artifact through CultMesh with visible owner/version evidence |
-
-## Negative Completion Checks
-
-The migration is not complete until all are true:
-
-- `CultVec2` and `CultVec3` cannot appear in newly generated schemas or native
- slice metadata.
-- CultMath cannot publish or execute planetary geometry through an old entrypoint.
-- VibeGeometry and `vg-csg` cannot influence a build, runtime, fixture, or
- generated artifact.
-- VibeGeometry is absent from project references, package manifests,
- submodules, CI, runtime identifiers, and authoritative documentation.
-- Legacy v1 state cannot override or repair current geometry after the fact.
-- CultCache cannot decompose a vector into independently owned scalar columns.
-- Manual, programmatic, imported, persisted, and network-received geometry use
- the same schema and commit paths.
-- Passing unit tests are accompanied by package inspection and cross-runtime
- evidence at the layer users actually consume.
-
-## Definition of Done
-
-The work is complete when `GameCult.Geometry` is the only geometry authority,
-CultMath is a clean numeric substrate, all retained runtime projections agree on
-typed layouts and stable identities, VibeGeometry has no live dependency path,
-and the old owners are structurally unable to emit current geometry state.
diff --git a/docs/cultmath-gamecult-geometry-migration-progress.md b/docs/cultmath-gamecult-geometry-migration-progress.md
deleted file mode 100644
index 74a473c5..00000000
--- a/docs/cultmath-gamecult-geometry-migration-progress.md
+++ /dev/null
@@ -1,320 +0,0 @@
-# CultMath and GameCult.Geometry Migration Progress
-
-Plan: `docs/cultmath-gamecult-geometry-migration-plan.md` (locked)
-
-Started: 2026-07-22
-
-Status: Stages 0-5 complete; Stage 6 consumer cutover implemented with two
-external verification/publication gates; Stage 7 source-authority cut complete
-
-## Objective
-
-Implement the locked migration so `GameCult.Geometry` becomes the sole owner of
-geometry, planetary geometry, CSG, geometry schemas, and runtime projections,
-while CultMath remains the HLSL-shaped numeric substrate and VibeGeometry loses
-all live authority.
-
-## Execution Authority Map
-
-- Owner: `GameCult.Geometry` owns geometry decisions and geometry state.
-- Inputs: CultMath numeric primitives, mined `vg-csg` implementation/evidence,
- current geometry v1 documents, and named downstream consumer contracts.
-- Outputs: GameCult.Geometry C#/Rust/HLSL/Unity/web surfaces, v2 typed schemas,
- persisted artifacts, and CultMesh projections.
-- Derived state: JSON inspection, UI/debug projections, caches, migration
- receipts, and compatibility reads are non-authoritative.
-- Forbidden writers: CultMath planetary APIs, v1 geometry runtime writers,
- `CultVec*`, VibeGeometry/`vg-csg`, legacy shader entrypoints, and anonymous
- fixed-vector arrays.
-- Shared paths: direct API calls, persisted reloads, imports, network envelopes,
- worker commands, Unity consumption, web tile reads, and Rust emitters must use
- the same current schema/layout/fingerprint rules.
-- Deletion line: `CultVec2`, `CultVec3`, `CultMath.rect`, CultMath planetary and
- erosion ownership, v1 writers, VibeGeometry wiring/submodule authority, and
- stale package/docs/schema paths.
-
-Demotions:
-
-- Geometry v1 DTOs are no longer owners; when a durable corpus requires them,
- they are bounded read-only migration inputs to v2.
-- JSON is no longer an owner; it is an inspection or xenos-boundary projection.
-- VibeGeometry is no longer an owner; it is a provenance-bearing source quarry.
-- CultMath planetary state is no longer an owner; it is source material pending
- transfer and deletion in the same unpublished migration cut.
-
-## Worktree Safety
-
-- CultLib began with unrelated modified `.voidbot`, CultMesh, CultCache TS,
- CultNet Rust, README, and test files plus scratch PID files. These changes are
- outside the migration unless an exact overlap is required and reviewed.
-- CultMath began clean on `codex/rust-cultmath-core` at `cb481b4`.
-- VibeGeometry began clean on `main` at `85ac60a`.
-- The locked plan is not edited during implementation.
-
-## Stage Ledger
-
-| Stage | State | Evidence / next gate |
-| --- | --- | --- |
-| 0. Freeze and baseline | Complete | Source bodies, contracts, consumers, licenses, fixtures, and focused baselines mapped |
-| 1. Destination/dependency/formatters/SoA | Complete | NuGet and dedicated UPM artifacts prove dependency direction and owned vector integration |
-| 2. Primitive deletion and v2 schemas | Complete | C# and Geometry-owned Rust emit identical v2 bytes and record keys for all four roots |
-| 3. Planetary transfer | Complete | Managed, HLSL, CMPT, Unity, web, tool, docs, tests, and package authority moved; CultMath owners deleted |
-| 4. Mine `vg-csg` | Complete | Geometry-owned Rust kernel passes correctness, parity, performance, formatting, and warning-denied lint gates |
-| 5. CultCache/CultNet/CultMesh integration | Complete | One provider commit path persists, watches, replicates, and probes typed geometry state |
-| 6. Consumer cutover/release | Gate pending | Fensalir green; Aetheria source cut pushed but full build/Unity regeneration await external dependency/editor state and immutable UPM tag |
-| 7. Delete obsolete authorities/audit | Complete | VibeGeometry workspace authority archived; tracked negative audit is clean apart from retained provenance/witnesses |
-
-## Verification Receipts
-
-Receipts will be appended here with command, repository, commit, result, and
-relevant artifact/version. A passing narrow test is not accepted as evidence for
-a broader stage gate.
-
-### Stage 0 receipts
-
-- CultLib: `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj --no-build --no-restore --disable-build-servers` passed 13/13. A full rebuild remains a Stage 1 gate; shared .NET build contention made attached build attempts exceed 60 seconds.
-- CultMath: `dotnet test CultMath.slnx --no-restore` passed 72/72.
-- CultMath native: `cargo test --locked` passed 1/1.
-- CultMath web tile unit: `node --test web/planetary-tile.test.mjs` passed 1/1.
-- CultMath pack and the combined web/C# cross-runtime script exceeded the
- baseline time window and remain required package-transfer gates.
-- VibeGeometry: `cargo test` passed 56 unit and 9 integration tests; doc tests
- passed; `cargo clippy --all-targets --all-features` passed.
-- Authoritative Rust quarry: VibeGeometry-pinned `vg-csg` submodule `a3197f4`.
- Standalone `E:\Projects\vg-csg` is stale and excluded.
-- v1 witnesses: current C#/Rust slots and hash algorithms match; Rust expected
- record keys include domain `02c9...ed95` and chunk `b94d...21af5`. Exact
- checked-in MessagePack byte witnesses still need to be generated before v2.
-- Persisted corpus: targeted file inventory covered CultLib, CultMath,
- VibeGeometry, Fensalir, and Aetheria. CultLib contains only the two checked-in
- `vg-csg-ragnarok` payload fixtures. Binary scanning of Aetheria `.cc`,
- `.msgpack`, and `.mpack` artifacts found no `gamecult.geometry.*.v1` catalog
- identity. Indexed-source retrieval found definitions/tests/docs but no durable
- geometry store. Execution therefore uses v2 plus retained test witnesses,
- with no live v1 runtime migrator.
-
-### Stage 1 receipts
-
-- CultCache MessagePack now resolves external member values through a
- document-owner assembly declaration. Resolver precedence is core CultCache,
- owner-declared resolvers, then MessagePack standard fallback; the options are
- cached per owner assembly and generated document codecs use that same path.
- CultCache does not reference Geometry or CultMath.
-- CultCache SoA discovery now admits direct reference-free value types as one
- exact typed column. It rejects managed-reference and byref-like values and
- does not synthesize component columns from nested struct fields.
-- `dotnet test tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj
- --no-restore --filter
- "FullyQualifiedName~DocumentAssemblyResolverTests|FullyQualifiedName~SoaValueColumnTests"
- --verbosity minimal -m:1 -nodeReuse:false` passed 4/4 after a full rebuild.
-- The initial SoA reflection probe used `Type.IsFunctionPointer`, which is not
- available on the library's `netstandard2.1` target. It was removed rather
- than papered over; structural managed-reference inspection is the owning
- eligibility rule.
-- CultMath candidate `0.1.0-geometry-migration.1` was packed from commit
- `cb481b4c75444426a693781a0e5b35ade08a8938` with build servers disabled. The
- 123,307-byte package SHA-256 is
- `07C0CE79453AF9BA29910809F8779916E7B8F92DCA7ACEB5E2B8F9D3EBED3E93`;
- a local-feed consumer restored, compiled, and executed `CultMath.float3`.
-- GameCult.Geometry consumes that candidate as an explicit NuGet dependency;
- its sibling `.tools/local-feed` is only an optional restore source, not a
- project-reference authority. Geometry owns strict positional formatters for
- `float2`, `float3`, `float4`, and `quaternion`.
-- `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj
- --no-restore --filter FullyQualifiedName~CultMathValueIntegrationTests
- --verbosity minimal --disable-build-servers -p:UseSharedCompilation=false
- -m:1`
- passed 6/6. Evidence covers exact bytes, round trips, malformed-width
- rejection, actual `Column`/`Column` access, and absence of
- synthetic component columns.
-- GameCult.Geometry candidate `0.1.0-geometry-migration.1` packed successfully;
- its nuspec declares exact candidate dependencies on CultMath, CultCache, and
- CultCache.MessagePack. The dedicated UPM artifact remains the open Stage 1
- gate.
-- Authoritative `vg-csg` commit `8f070f4` now pins exact v1 MessagePack bytes
- for representative domain/chunk tuples plus complete domain, request,
- selected-cut, and chunk record keys. Focused Rust tests passed 4/4; formatting
- and clippy passed. VibeGeometry commit `82b56db` advances its submodule to
- that witness commit. Both migration branches are pushed.
-- `scripts/build-geometry-unity-package.ps1` staged
- `org.gamecult.geometry@0.1.0` successfully. Its manifest depends explicitly
- on `org.gamecult.cultmath@0.1.0` and `org.gamecult.cultlib@1.0.7`; the package
- contains only its owned `GameCult.Geometry.dll`/symbols plus its asmdef and
- documentation. It neither duplicates CultMath nor buries Geometry inside the
- CultLib Unity package.
-
-## Decisions and Deviations
-
-- Licensing: no relicensing is assumed. Moved CultMath files retain MPL-2.0
- notices and provenance; the GameCult.Geometry package will carry explicit
- mixed-license package documentation rather than falsely stamping those files
- MIT. `AdvancedErosionFilter` source and shader notices remain verbatim.
-- Serialization: Geometry-owned external value formatters require a declarative
- per-document-assembly resolver seam in `GameCult.Caching.MessagePack`.
- CultMath remains annotation-free and core caching does not reference Geometry.
-- v1 persistence: no durable corpus was found in the named repositories and
- artifacts. v1 remains fixture evidence only; it will not survive as a runtime
- writer or general compatibility model.
-- No deviations from the locked target architecture.
-- Stage 2 versions all four root geometry documents as one v2 cohort. Domain's
- nested CLR layout changes are invisible to the current root-only schema
- fingerprint, and leaving SelectedCut as the lone live v1 writer would retain
- a split cohort even though its direct fields are unchanged.
-- Typed vectors preserve existing record keys by flattening components in the
- same authored order into the existing IEEE-754 bit canonicalization. Wire
- payloads still change—especially typed mesh arrays—so byte fixtures and v2
- identities remain mandatory.
-
-### Stage 2 receipts
-
-- `CultVec2` and `CultVec3` definitions were deleted; `CultRect`, `CultCircle`,
- and `CultSphere` now expose CultMath `float2`/`float3` directly. No alias or
- conversion owner remains.
-- `dotnet test tests/GameCult.Geometry.Tests/GameCult.Geometry.Tests.csproj
- --no-restore --verbosity minimal --disable-build-servers
- -p:UseSharedCompilation=false -m:1` passed 19/19 for the primitive cut.
-- CultMesh native-slice metadata fixtures now name `CultMath.float2` and
- `CultMath.float3` while retaining 8/12-byte element widths. Only those three
- overlapping lines were staged from the already-dirty TypeScript test file.
-- All four root geometry schemas now form a v2 cohort with their original slot
- numbers. Semantic single vectors use `float3`/`quaternion`; mesh vertices,
- normals, and UVs use `float3[]`/`float2[]`. The runtime contains no v1 DTO,
- reader, writer, or conversion authority.
-- Live v2 semantic SchemaIds are pinned as domain `sha256:e335...421d`, build
- request `sha256:2de3...21d3`, selected cut `sha256:8644...9ff8`, and chunk
- artifact `sha256:2d28...6156`. Full values live in the executable tests.
-- Typed stable fingerprints flatten components into the prior ordered IEEE-bit
- stream. Domain/chunk round trips preserve record identity, while the checked-
- in flat-mesh v1 payload is retained as negative evidence and rejected by the
- v2 decoder. The Geometry suite passes 19/19 after the cohort cut.
-- `packages/gamecult-geometry-rs` is the Geometry-owned Rust v2 destination.
- It has no VibeGeometry, `vg-csg`, `bevy_math`, CultCache, or CultNet
- dependency and preserves the MIT provenance of the mined `vg-csg` contract.
-- C# and Rust pin identical exact MessagePack bytes and record keys for domain,
- build request, selected cut, and chunk roots. C# decodes the Rust witnesses
- and re-emits the same bytes. The Geometry suite passes 24/24; Rust passes 4/4,
- doc tests, rustfmt, and warning-denied clippy.
-- The parity pass exposed an old split identity: C# request scalar floats used
- round-trip decimal text while `vg-csg` used IEEE-bit hex. v2 deliberately
- standardizes every float on IEEE bits. The request key therefore changes at
- the v2 boundary; signed zero, a chosen NaN payload, and infinity are pinned
- across MessagePack so this cannot quietly diverge again.
-
-### Stage 3 receipts
-
-- Thirteen planetary/erosion C# sources and thirteen xUnit suites now live
- under GameCult.Geometry; `CultMath.rect`, its factories, and all original
- planetary sources/tests are deleted. Destination planetary tests pass 41/41;
- remaining CultMath tests pass 28/28 for both target frameworks.
-- Geometry owns five HLSL files behind `GameCult.Geometry.hlsl`; public geometry
- symbols use `gamecult_geometry_*`. CultMath's umbrella and NuGet candidate
- contain only numeric `CultMath.hlsl`. A DXC SPIR-V smoke compiled the joined
- umbrella successfully to a 1,428-byte module.
-- The CMPT v1 tool and TypeScript decoder moved intact. The end-to-end fixture
- is exactly 1,221 bytes with SHA-256
- `8b2bc46e4123b8ac8936b43f8d08a34e3f2e74f38d0bb3f101cf3728cd201962`;
- the C# emitter and TypeScript decoder pass together.
-- Unity adapters retain their original `.meta` GUIDs; the Planetary Viewer uses
- the Geometry umbrella and owned shader symbols. The staged UPM artifact has
- one owned DLL, both adapters, the sample, all five shaders, and explicit
- MIT/MPL license and provenance documents.
-- Stale candidate cache was made impossible by rotating CultMath and Geometry
- to `0.1.0-geometry-migration.2`. CultMath's candidate contains only its DLLs,
- README, and numeric shader. Geometry's candidate declares the exact CultMath
- `.2` dependency and contains all Geometry shaders/docs/notices under a
- mixed-license file declaration rather than the inherited blanket MIT claim.
-- Full Geometry tests pass 27/27 after the joined shader/Unity integration;
- planetary tests pass 41/41. Negative searches find obsolete CultMath geometry
- names only inside tests that assert their absence or ignored generated output.
-
-### Stage 4 receipts
-
-- `packages/gamecult-geometry-rs` now owns the mined convex splitting, ordered
- brush CSG, tree lowering, dirty frontiers, prefix checkpoints, domain/LOD
- selection, feature lowering, and triangle/collider assembly kernels. No build
- or runtime dependency points at VibeGeometry or `vg-csg`.
-- The public `GeometryBrushAssembler` facade accepts Geometry-owned
- `Float3` values and returns `GeometryTriangleMesh`; `bevy_math` remains a
- private implementation dependency and is not re-exported.
-- The duplicate quarry persistence authority (`DomainSpecDocument`,
- `DomainNodeDocument`, `FeatureClaimDocument`, and its local schema version)
- was deleted. Private specs are in-memory builders; the v2 `Geometry*`
- documents remain the sole wire authority.
-- Nine RealtimeCSG behavioral fixtures and a bounded 512-distant-cutter
- performance fixture moved under Geometry ownership. `cargo test --locked`
- passes 67/67 plus doc tests; rustfmt and warning-denied Clippy pass.
-- The authority transfer commits are CultLib `756f5a33`, CultMath `6a16260`,
- and the post-transfer label cleanup `99c893bd`. The Rust absorption commit is
- CultLib `6b44f969`.
-
-### Stage 5 receipts
-
-- `CultGeometryWorkerProvider` owns the build invocation and output commit
- sequence. Direct calls and the `gamecult.geometry.worker.build` CultMesh
- operation share `BuildAsync`; compute is injected through the persistence-free
- `ICultGeometryBuildPipeline` port.
-- Domain/request reads and selected-cut, artifact, and observational worker-state
- writes use `CultNetDatabase` typed APIs. Watches and raw MessagePack envelopes
- are consequences of that commit path rather than parallel publishers.
-- The development probe derives owner, v2 schema, request/cut keys, selected
- nodes, artifact identities, content hashes, and served assembly version from
- persisted documents. Mutating worker display state cannot overwrite geometry
- outputs or forge the served version.
-- Worker state registers through the generated CultDocument registry. Focused
- provider tests pass 4/4 and the full GameCult.Geometry suite passes 31/31.
-- Rust raw-envelope duplication was deliberately omitted: CultNet owns that
- protocol. Existing C#/Rust exact v2 document bytes and record keys remain the
- cross-runtime geometry witness.
-
-### Stage 6 receipts
-
-- Coordinated local candidates use
- `scripts/build-geometry-candidate.ps1`. CultMath `.2` is primitives-only;
- Geometry `.2` contains its managed assembly, notices/docs, and all five owned
- shaders with exact support-package and CultMath candidate dependencies. A
- clean net10 consumer restored and built; the Unity package staged one owned
- assembly plus adapters, sample, and shaders. The Rust crate passed an
- independent `cargo package --allow-dirty --no-verify` dry run.
-- Fensalir cutover commit `42778c1` adds direct Geometry dependencies, migrates
- planetary C# consumers, compiler include roots, shaders, and parity harnesses
- while retaining CultMath for numeric primitives. The solution builds with no
- warnings/errors; fractal tests pass 170/170, erosion parity 1/1, topology
- parity 19/19, and planetary page tests 16/16.
-- Aetheria cutover commits `26948f81` and `557bc71f` replace `CultVec*` fixtures
- with CultMath vectors under an exact legacy MessagePack byte witness, move
- spatial rectangles to `GameCult.Geometry.CultRect`, add the real .NET/Unity
- Geometry dependencies, and remove stale ownership guidance.
-- Aetheria's geometry compiler errors are gone. Its full build is presently
- blocked by concurrent CultMesh API drift (`CultMeshBody*` and persisted-record
- types), outside this migration. Unity regeneration is also deferred while an
- active Unity editor owns the project. The coordinated local file dependency
- must become an immutable Geometry Unity tag before publication.
-- Legacy D3DCompiler exposed that macro-expanded HLSL includes were not
- portable. CultLib commit `cdeea474` gives the Geometry umbrella direct default
- and Unity include branches; Geometry shader ownership tests pass 3/3 and the
- downstream D3D12 corpus passes.
-
-### Stage 7 receipts
-
-- Tracked negative audits across CultLib, CultMath, Fensalir, Aetheria, and
- VibeGeometry find no live `CultVec*`, `CultMath.rect`, CultMath planetary
- implementation, old planetary shader vocabulary, geometry v1 writer, or
- VibeGeometry/`vg-csg` build dependency. Frozen fixtures, source attribution,
- and negative ownership tests remain deliberately.
-- VibeGeometry commit `bc9604a` removes its tracked Cargo workspace, lockfile,
- submodule declaration/gitlink, scratch/build wiring, and live mission claims.
- Its remaining documents point to source witness `8f070f4f` and Geometry
- absorption commit `6b44f969`; repository history preserves provenance.
-- CultLib commits `8a7eee71` and `b9f20a64` remove manual-registry guidance and
- update the root package map. Generated CultDocument metadata remains the only
- registration owner.
-
-## Next Actions
-
-1. Resolve Aetheria's concurrent CultMesh API/worktree drift, then rerun its
- State/Freeze verifier and daemon smoke against this migration branch.
-2. Close the active Unity editor/import workers, regenerate Aetheria Unity
- project/lock files through Unity, and run the canonical EditMode suite.
-3. Publish an immutable `org.gamecult.geometry` Unity package tag in the
- coordinated release window, replace Aetheria's local candidate reference,
- and repeat package/version inspection before declaring Stage 6 complete.
diff --git a/docs/cultnet-transport-parity.md b/docs/cultnet-transport-parity.md
index 5f0fec91..d0779196 100644
--- a/docs/cultnet-transport-parity.md
+++ b/docs/cultnet-transport-parity.md
@@ -31,7 +31,7 @@ CultNet owns cross-runtime transport semantics:
| C# interop peer | Schema-v0 MessagePack over TCP-framed or shared RUDP transport | UDP multicast probe/announce with TCP and RUDP transport profiles | Test harness transport parity |
| TypeScript `cultnet-ts` | `CultNetPeer` over any Node `Duplex`, TCP-framed transport, or single-peer RUDP socket transport; interop uses TCP | UDP multicast probe/announce in the interop peer | First UDP socket binding for the shared RUDP reliability owner |
| Rust `cultnet-rs` | Interop example serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport; single-peer RUDP socket transport exists in the library | UDP multicast probe/announce with TCP and RUDP transport profiles | UDP socket binding for the shared RUDP reliability owner |
-| Python `cultcache-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner |
+| Python `cultnet-py` | TCP sockets with 4-byte length-prefixed MessagePack frames for local CultMesh/CultNet server and client; single-peer RUDP socket transport exists in the library | Endpoint lists and CultMesh peer/Verse catalogs | UDP socket binding for the shared RUDP reliability owner |
| Kotlin `cultmesh-kotlin` | Channel-aware WebSocket transport connection for reliable ordered `schema` frames; single-peer RUDP socket transport exists in the library; interop CLI serves and dials schema-v0 MessagePack over TCP-framed or shared RUDP transport | CultMesh Verse and peer catalogs, plus endpoint lists carried in schema-v0 catalog messages | WebSocket/TCP adapters plus UDP socket binding for the shared RUDP reliability owner, with build-script and TypeScript harness proof |
The live split is therefore: payload language and the native RUDP packet/session
@@ -258,11 +258,19 @@ Current progress:
echo against C#/Kotlin/Python/Rust UDP peers; local runtime tests prove timeout
state transitions.
- TypeScript, C#, Rust, Python, and Kotlin now share bounded reliable-send
- backpressure for RUDP sessions. `maxPendingReliablePackets` is advertised on
- RUDP transport-profile channels and enforced by the session before reliable
- connect, accept, single-packet sends, or fragmented sends enqueue anything;
- TCP-framed profiles do not publish the field because they do not own the RUDP
- pending-reliable queue.
+ backpressure for RUDP sessions. At most 32 reliable packets are admitted to
+ the wire at once; later fragments remain in a session-owned FIFO and
+ acknowledgements promote the next packets. Receivers emit a cumulative ACK
+ for packets inside the 32-sequence ACK horizon and an exact ACK for an older
+ retransmit, so unrelated unreliable traffic cannot strand reliable work.
+ This keeps the send frontier well
+ inside the 4,096-sequence receive history instead of blasting an entire large
+ document and hoping the UDP buffers impersonate flow control.
+ `maxPendingReliablePackets` bounds both queued and in-flight packets before a
+ reliable frame is accepted. Socket transports expose an explicit reliable
+ flush where the runtime supports one, so callers that require confirmed
+ transport delivery do not substitute a fixed sleep. TCP-framed profiles do
+ not publish the field because they do not own the RUDP pending-reliable queue.
- TypeScript, C#, Rust, Python, and Kotlin now share a portable
`cultnet.reconnect_policy.v0` document and deterministic exponential delay
helper, and RUDP transport profiles advertise that policy under
diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md
index a269090a..afc94570 100644
--- a/docs/nuget-packaging.md
+++ b/docs/nuget-packaging.md
@@ -19,5 +19,5 @@ closure through UPM rather than NuGet restore.
Install the Unity package from the immutable release tag:
```text
-https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.8
+https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46
```
diff --git a/docs/parked-features.md b/docs/parked-features.md
new file mode 100644
index 00000000..aa65294b
--- /dev/null
+++ b/docs/parked-features.md
@@ -0,0 +1,32 @@
+# Parked Features
+
+A parked feature was built for a stated reason, has no consumer, and was
+removed from the tree. Its last working form lives at an annotated tag so it
+can be restored instead of reinvented.
+
+## CultCache SoA table
+
+- Tag: `parked/cultcache-soa`
+- Intent: ECS-style structure-of-arrays columns over cached documents, for
+ hot-loop performance without giving up POCO document ergonomics.
+- Span at the tag: `src/GameCult.Caching/CultManagedDocument.cs:133-414`
+ (`CultSoaTable`, `CultSoaColumn`, `CultCacheSoaStore`,
+ `CultCacheSoaTypeTable`, `CultCacheSoaMember`); `CultCache.Soa()` and the
+ `_soa` field with its upsert/remove hooks in `CultCache.cs`; the SoA tests in
+ `tests/GameCult.Caching.Tests/BackingStoreTests.cs` and
+ `tests/GameCult.Mesh.Tests/CultMeshStreamingTests.cs`.
+- Managed document: `CultManagedDocument` (`CultManagedDocument.cs:72-131` at
+ the tag) with `CultCache.Document` and `CultNetDatabase.Document`, the
+ reactive POCO handle added with the SoA storage in 5428870 "Make SoA
+ cache-managed document storage". Same tag, same intent: the document stays a
+ POCO while the cache owns its columnar storage.
+- Tag message correction: the `parked/cultcache-soa` tag message says
+ `CultManagedDocument` was "deleted separately, not parked". This note
+ supersedes it: `CultManagedDocument` is parked under the same tag and is
+ restored alongside SoA. The tag itself is left as written.
+- Consumers when parked: none outside tests.
+- Restore: `git show parked/cultcache-soa:src/GameCult.Caching/CultManagedDocument.cs`,
+ take the SoA types and `CultManagedDocument`, restore both `Document`
+ openers from `CultCache.cs` and `CultNetDatabase.cs` at the tag, and re-hook a
+ `CultCacheSoaStore` into the cache's single
+ admission and eviction paths (`Admit`/`Evict`).
diff --git a/docs/persisted-record-encoding.md b/docs/persisted-record-encoding.md
new file mode 100644
index 00000000..b8e13f8a
--- /dev/null
+++ b/docs/persisted-record-encoding.md
@@ -0,0 +1,68 @@
+# The encoding of a persisted CultCache record is part of its contract
+
+Changing how a stored record serializes is never a flag flip in this estate.
+Consumers verify canonicality by **re-encoding what they decoded and comparing
+bytes**, so a record that reads back correctly but re-encodes differently is
+refused — and the refusal happens on live data, at startup or on the next read.
+
+Three guards, three separate stores, one idiom:
+
+| store | guard | refusal |
+|---|---|---|
+| Idunn runtime projection | `Idunn/src/drivers.rs:3611` | `service trust-anchor projection is noncanonical` |
+| Idunn deployment brake | `Idunn/src/control_plane.rs` (`read_deployment_brake`) | `deployment brake is noncanonical or keyed by another authority` |
+| Idunn trust bindings | `Idunn/src/provisioning.rs:546`, `:630` | `trust store contains a noncanonical or mismatched binding` |
+
+Idunn's control store applies the same rule to its own transactions. Assume the
+guard exists and go looking for it before changing any persisted record's
+serialization; three independent instances is a house pattern, not a
+coincidence.
+
+## What this means for `#[cultcache(key = N, bytes)]`
+
+The attribute switches a `Vec` slot from a MessagePack array-of-integers
+(serde's default) to a real `bin`. Measured, not assumed
+(`packages/cultnet-rs/tests/byte_slot_migration.rs`):
+
+- **Reading stays compatible in both directions.** `serde_bytes::ByteBuf`
+ accepts an array marker, and a plain `Vec` accepts `bin`. No dual-read
+ path is needed, and reverting a binary is survivable.
+- **Reproduction does not.** A record written before the flip can be read but
+ can never re-encode to the bytes it was stored as, and its `canonical_sha256`
+ moves with it.
+
+So the cost lands entirely on the guards above, not on readability. A field with
+persisted records must flip the attribute **and** rewrite its store in the same
+change, one target at a time, and never during a deployment — the projection
+store is what the observer reads, and refusing it takes the observer down.
+
+A field with no live producer is free. Check for one before assuming a
+migration: `IdunnAuthenticatedProviderHealthProjectionRecord` is constructed
+only inside a test module, so its byte field costs nothing to convert.
+
+Size, for whether it is worth doing at all: 406 bytes as an array against 278 as
+`bin` for a 256-byte payload, and 1234 against 851 for an 848-byte one. About
+1.45x on every byte, which matters on a media path and is noise on a control
+record.
+
+## `Option>` cannot take the attribute yet
+
+The derive emits `serde_bytes::Bytes::new(&self.field)`, which needs `&[u8]`,
+and builds the field via `.into_vec()`, which yields `Vec`. Both are type
+errors against an `Option>`, so the derive fails loudly rather than
+collapsing "absent slot" into "unsigned". `IdunnDeploymentBrakeRecord.signature`
+needs an `Option`-aware path in `cultcache-rs-derive` before it can move.
+
+## Fields validated for shape and produced by nothing
+
+`trust_binding_sha256` is declared on two records with different schemas
+(`idunn.authenticated_provider_health_projection.v1` in cultnet-rs,
+`idunn.authenticated_daemon_health_admission.v1` in odin-core), shape-validated
+in both, and computed by nothing in CultLib, Idunn or Odin.
+
+Do not assume that means "omission". It may be a fossil — a contract whose
+producer was deleted. The estate has form: Muninn's receiver runs a Reed-Solomon
+audio FEC path against parity shards the Rust sender has never emitted, while an
+acceptance ledger describes that FEC as having worked. Settling which requires
+reading history, not source, and the answer changes whether the right move is to
+implement the field or delete it.
diff --git a/packages/cultcache-py/PARITY.md b/docs/python-runtime-parity.md
similarity index 96%
rename from packages/cultcache-py/PARITY.md
rename to docs/python-runtime-parity.md
index 18607d87..1cc222b7 100644
--- a/packages/cultcache-py/PARITY.md
+++ b/docs/python-runtime-parity.md
@@ -1,7 +1,7 @@
# Python Runtime Parity Map
-This file is the current audit map for `cultcache-py` as a Python runtime for
-CultCache, CultNet, and CultMesh. It is evidence, not confetti.
+This file is the current audit map for the Python runtime packages
+`cultcache-py`, `cultnet-py`, and `cultmesh-py`. It is evidence, not confetti.
## Proven Surfaces
@@ -255,14 +255,14 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti.
individual discovery, peer exchange, or simulation surfaces unserved without
advertising disabled capabilities.
- Python package health:
- `cultcache-py-verify --json` checks the public CultCache/CultNet/CultMesh
+ `cultmesh-py-verify --json` checks the public CultCache/CultNet/CultMesh
export surface declared by each package `__all__`, `py.typed` markers, a live
local CultMesh framed-wire smoke over hello/schema/snapshot/shard
catalog/shard log, typed peer-error export availability, peer-advertised
mutation contracts, payload schema wire contracts, simulation capability
truth, and benchmark sanity for Python-owned hot paths.
- Python/C# public cache baseline:
- `cultcache_py.compare_csharp` runs Python and C# `CultCache` upsert/get
+ `cultnet_py.compare_csharp` runs Python and C# `CultCache` upsert/get
benchmarks with the same operation names and reports median Python-to-C# ratios
from three samples by default. It also emits per-metric threshold checks and an
overall parity status so performance claims have an explicit evidence gate.
@@ -275,12 +275,14 @@ CultCache, CultNet, and CultMesh. It is evidence, not confetti.
## Local Python Gates
```powershell
-$env:PYTHONPATH="$PWD\packages\cultcache-py\src"
+$env:PYTHONPATH="$PWD\packages\cultcache-py\src;$PWD\packages\cultnet-py\src;$PWD\packages\cultmesh-py\src"
python -m unittest discover -s packages\cultcache-py\tests
-python -m cultcache_py.verify --json
-python -m cultcache_py.benchmark --records 1000 --json
-python -m cultcache_py.compare_csharp --records 1000 --samples 3 --json
-python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py
+python -m unittest discover -s packages\cultnet-py\tests
+python -m unittest discover -s packages\cultmesh-py\tests
+python -m cultmesh_py.verify --json
+python -m cultnet_py.benchmark --records 1000 --json
+python -m cultnet_py.compare_csharp --records 1000 --samples 3 --json
+python -m pip wheel --no-deps -w $env:TEMP packages\cultcache-py packages\cultnet-py packages\cultmesh-py
```
## Cross-Runtime Gate
diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md
index cd3761d7..44715c7c 100644
--- a/docs/runtime-parity-scope.md
+++ b/docs/runtime-parity-scope.md
@@ -21,20 +21,23 @@ Cross-runtime parity has three layers:
transport helpers instead of assembling raw maps and channel strings.
SoA performance parity is deliberately not a cross-runtime requirement. C#
-owns the CPU-local SoA table and shared-memory streaming body path. Other
-runtimes may add native hot paths when their runtime role earns them, but they
-do not need to mimic Unity/C# memory layout to speak CultNet or participate in
-CultMesh.
+owns the shared-memory streaming body path. The C# CultCache SoA table (ECS-style
+structure-of-arrays columns over cached documents) is parked at the tag
+`parked/cultcache-soa`: it was built for hot-loop performance, had no consumer
+outside tests, and is restorable from that tag (`docs/parked-features.md`).
+It is not a supported surface in the current tree. Other runtimes may add native hot paths when their
+runtime role earns them, but they do not need to mimic Unity/C# memory layout
+to speak CultNet or participate in CultMesh.
## Current Targeted Runtimes
| Runtime | Role | Claimed parity | Not claimed |
| --- | --- | --- | --- |
-| C# | Reference implementation and production game/server substrate | CultCache typed documents and SoA, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
+| C# | Reference implementation and production game/server substrate | CultCache typed documents, CultNet schema-v0, LiteNetLib production path with adapter profile, shared transport profiles, shared RUDP socket transport, C# multi-peer RUDP listener and schema service host, C# interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP-backed schema-client selection for `rudp://` service endpoints, RUDP reconnect policy/controller and reconnect-loop helper, LiteNetLib client reconnect-controller adoption, schema-client transport port for service request/response clients, CultMesh node/session/catalog/stream/simulation/RUDP surfaces, authorized peer lookup | No need to abandon LiteNetLib before the CultNet adapter fully owns production service adoption |
| Python | Package runtime, local daemon endpoint, raw interop peer, typed state participant | CultCache/CultNet/CultMesh package surface, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup, shard catalogs/logs, schema-transport factory for raw client request/subscription flows, local TCP and RUDP CultMesh server listeners, Python interop RUDP serve/dial path, session/prediction/simulation fact helpers, RUDP codec/session/socket transport, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, benchmark evidence against C# cache paths | Universal C# throughput, full C# daemon/server stack, and RUDP as the only public service pipe |
| TypeScript | Node/browser-adjacent projection and interop runtime | CultCache parity tests, CultNet schema-v0 peer, advertised schema/shard catalog wire contracts plus request/sync helpers, transport profiles, TCP-framed transport port and peer helper, shared RUDP socket transport, RUDP interop serve/dial path, RUDP reconnect policy/controller, RUDP reconnect-loop helper, branded CultMesh node/schema/shard/Verse/peer/authority/stream/RUDP ergonomics, authorized peer lookup | Full C# game session/server stack |
-| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade, stream catalog, or production game-session server |
-| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store, full C# server daemon, or Unity hot-loop memory layout |
+| Rust | Low-level cache/transport/runtime substrate | CultCache/CultNet interop lane, schema and shard catalog helpers, typed same-schema alias hydration from raw snapshots, transport profiles, TCP-framed port, shared RUDP codec/session/socket transport, Rust interop RUDP serve/dial path, connected RUDP CultMesh client helpers, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence, small CultMesh facade for schema registry, shard catalog, peer cards, authority leases, authorized peer lookup, RUDP endpoint parsing, and peer-card/authorized-peer RUDP client construction | Full CultMesh game-session/server facade or production game-session server. `packages/cultmesh-rs` now holds a Rust stream and session surface -- node, document sets, RUDP document publication, stream descriptors, negotiation, stream catalog, and shared-memory frame rings -- but no interop lane exercises it, so it carries no cross-runtime parity claim |
+| Kotlin | JVM/Android client substrate | Typed MessagePack cache, one-call typed snapshot document sync through local same-schema aliases, built-in schema catalog advertisement, schema/shard catalogs with shared schema-message transport fetch/sync helpers, authority leases, authorized peer lookup, stream catalog, branded `CultMesh` facade, WebSocket transport profile/connection helper, RUDP client/server sugar, connected RUDP CultMesh client helpers, Kotlin interop RUDP serve/dial path, RUDP reconnect policy/controller and reconnect-loop helper, schema-v0 MessagePack over RUDP evidence | C# SoA store (parked at `parked/cultcache-soa`), full C# server daemon, or Unity hot-loop memory layout |
## Evidence Gates
@@ -52,7 +55,7 @@ CultMesh.
`reconnectPolicy` profile field from C#, Rust, Python, and Kotlin peers.
Runtime-local tests cover the deterministic delay helper and reconnect
controller in C#, TypeScript, Rust, Python, and Kotlin.
-- `packages/cultcache-py/PARITY.md` is the Python-specific audit ledger. Its
+- `docs/python-runtime-parity.md` is the Python-specific audit ledger. Its
"Still Not Claimed" section is part of the contract, not an apology.
- `docs/cultnet-transport-parity.md` is the transport ownership map. It tracks
the remaining work to lower older LiteNetLib/WebSocket bodies behind the
@@ -85,6 +88,16 @@ server body, is deliberately not made.
backfilling C# server responsibilities it should not own. Kotlin WebSocket
and RUDP adapters now share one `CultNetSchemaMessageTransport` helper shape
for schema/shard catalog request and sync flows.
+- Bring `packages/cultmesh-rs` under the wire claim. It arrived from a consumer repo where it grew
+ outside any conformance harness, and its stream, negotiation, and shared-memory frame-ring
+ surfaces have never been checked against the browser, Kotlin, and TypeScript CultMesh bodies
+ they must interoperate with. Runtime-local tests pass; that is not a parity claim.
+- Carry the RUDP receive-path bounds to the other runtimes. Rust now caps tracked ordered
+ and sequenced channels, caps the out-of-order hold buffer by frames and bytes, refuses a
+ packet far ahead of the highest received so a forged sequence cannot starve a session, and
+ reports reliable sequence exhaustion instead of aborting. C#, TypeScript, Python and Kotlin
+ still accept all of it. These are refusals rather than wire-format changes, so the interop
+ lanes stay green either way -- which is exactly why the gap will not announce itself.
- Keep performance claims evidence-bound: C# owns SoA; Python has benchmark
comparison gates; other runtimes should advertise measured native hot paths
only when those paths exist.
diff --git a/package-lock.json b/package-lock.json
index bc42f617..f027ecd5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,15 @@
"workspaces": [
"packages/cultcache-ts",
"packages/cultnet-ts",
- "packages/cultmesh-ts"
- ]
+ "packages/cultmesh-ts",
+ "packages/cultmesh-browser"
+ ],
+ "devDependencies": {
+ "esbuild": "^0.28.1",
+ "fast-uri": "3.1.5",
+ "js-yaml": "4.3.1",
+ "playwright-core": "^1.62.1"
+ }
},
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.9.3",
@@ -29,6 +36,452 @@
"url": "https://github.com/sponsors/philsturgeon"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@gamecult/cultcache-ts": {
+ "resolved": "packages/cultcache-ts",
+ "link": true
+ },
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
@@ -92,8 +545,8 @@
"dev": true,
"license": "Python-2.0"
},
- "node_modules/cultcache-ts": {
- "resolved": "packages/cultcache-ts",
+ "node_modules/cultmesh-browser": {
+ "resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/cultmesh-ts": {
@@ -104,6 +557,48 @@
"resolved": "packages/cultnet-ts",
"link": true
},
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -111,9 +606,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -168,9 +663,9 @@
}
},
"node_modules/js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -250,6 +745,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
@@ -323,7 +831,8 @@
}
},
"packages/cultcache-ts": {
- "version": "0.13.5",
+ "name": "@gamecult/cultcache-ts",
+ "version": "0.14.0",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
@@ -337,13 +846,28 @@
"node": ">=18"
}
},
+ "packages/cultmesh-browser": {
+ "version": "0.3.0",
+ "license": "MIT",
+ "dependencies": {
+ "@msgpack/msgpack": "^3.1.3",
+ "cultnet-ts": "^0.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.0.0",
+ "typescript": "^5.9.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"packages/cultmesh-ts": {
"version": "0.1.1",
"license": "MIT",
"dependencies": {
+ "@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.2",
- "cultcache-ts": "^0.13.5",
- "cultnet-ts": "^0.1.1"
+ "cultnet-ts": "^0.2.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
@@ -355,12 +879,12 @@
}
},
"packages/cultnet-ts": {
- "version": "0.1.1",
+ "version": "0.2.0",
"license": "MIT",
"dependencies": {
+ "@gamecult/cultcache-ts": "^0.14.0",
"@msgpack/msgpack": "^3.1.3",
- "ajv": "^8.17.1",
- "cultcache-ts": "^0.13.5"
+ "ajv": "^8.17.1"
},
"devDependencies": {
"@types/node": "^24.0.0",
diff --git a/package.json b/package.json
index 09a80f89..c9b79e47 100644
--- a/package.json
+++ b/package.json
@@ -5,11 +5,18 @@
"packages/cultcache-ts",
"packages/cultnet-ts",
"packages/cultmesh-ts",
- "packages/gamecult-geometry-ts"
+ "packages/cultmesh-browser"
],
"scripts": {
"build:ts": "node scripts/run-typescript-workspaces.mjs build",
"test:ts": "node scripts/run-typescript-workspaces.mjs test",
- "test:pack:ts": "node scripts/test-typescript-package-closure.mjs"
+ "test:pack:ts": "node scripts/test-typescript-package-closure.mjs",
+ "test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs"
+ },
+ "devDependencies": {
+ "esbuild": "^0.28.1",
+ "fast-uri": "3.1.5",
+ "js-yaml": "4.3.1",
+ "playwright-core": "^1.62.1"
}
}
diff --git a/packages/cultcache-py/README.md b/packages/cultcache-py/README.md
index 48c023ba..23effdbd 100644
--- a/packages/cultcache-py/README.md
+++ b/packages/cultcache-py/README.md
@@ -1,12 +1,16 @@
-# cultcache-py
+# cultcache-py
-`cultcache-py` is the Python runtime package for CultCache, CultNet, and
-CultMesh. Callers work with registered domain documents while the cache owns
-schema identity, routing, globals, name lookups, indexes, and backing-store
-envelopes.
+`cultcache-py` is the Python CultCache runtime. Callers work with registered
+domain documents while the cache owns schema identity, routing, globals, name
+lookups, indexes, and backing-store envelopes.
It is intentionally small. It is not an ORM, a database, or distributed consensus wearing rented authority.
+It is the bottom of the Python CultLib stack and depends only on `msgpack`.
+`cultnet-py` (transport and wire contracts) and `cultmesh-py` (nodes,
+discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
+`cultnet-rs` / `cultmesh-rs` and the npm packages.
+
## Current Shape
- document types are registered explicitly
@@ -17,14 +21,14 @@ It is intentionally small. It is not an ORM, a database, or distributed consensu
- payloads decode only through the registered document definition for that type
- unknown persisted types fail closed
- global documents are singleton-style per type
-- type-specific backing stores beat generic backing stores
+- one home store per document type: a store added with `add_backing_store`
+ owns its types, otherwise the generic store is the home; a type claimed by a
+ second store, or a second generic store, raises `CultCacheError`; a write
+ touches exactly one store and nothing is mirrored; once any store is
+ attached, a write whose type has no home raises
- the JSONL store uses only the Python standard library for bootstrap consumers
- `define_database_entry_type(...)` emits Rust/C#-style slot-indexed
MessagePack array payloads for cross-runtime `DatabaseEntry` contracts
-- `cultnet_py` exposes schema-v0 MessagePack helpers and 4-byte big-endian
- frame helpers for raw document state, subscriptions, shard catch-up,
- simulation observations, and witness artifact bundles
-- `cultmesh_py` exposes a cache-backed local node surface for Python tools
## Example
@@ -144,382 +148,28 @@ That matches the Rust `#[derive(DatabaseEntry)]` formatter shape and the C#
`[Key(n)]` intent: schema evolution comes from stable field slots, not source
member order.
-## CultNet And CultMesh
-
-`cultnet_py` provides the Python schema-v0 wire helpers and an interop peer:
-
-```python
-from datetime import UTC, datetime
-
-from cultnet_py import (
- CultNetSecret,
- CultNetServerSecurityOptions,
- CultNetRawClient,
- CultNetSimulationObservation,
- CultNetWitnessArtifactBundle,
- apply_raw_snapshot,
- apply_shard_log_response,
- compute_simulation_claim_hash,
- database_subscribe,
- decode_frame,
- encode_frame,
- hello,
- parse_message,
- shard_catalog_request,
- simulation_observation,
- witness_artifact_bundle,
-)
-from cultmesh_py import CultMesh
-
-payload = hello(runtime_id="python-runtime").to_bytes()
-message = parse_message(decode_frame(encode_frame(payload)))
-
-client = CultNetRawClient("127.0.0.1", 3075)
-catalog = client.fetch_schema_catalog(kinds=["wire_message"])
-snapshot = client.fetch_snapshot_response(schema_ids=["cultnet.interop-note"])
-shard_catalog = client.fetch_shard_catalog(schema_ids=["cultnet.interop-note"])
-# With a local CultCache and the matching registered document definitions:
-# applied = apply_raw_snapshot(cache, [note_doc], snapshot)
-
-subscription = database_subscribe(subscription_id="ui", schema_ids=["cultnet.interop-note"])
-shard_request = shard_catalog_request(message_id="shards", schema_ids=["cultnet.interop-note"])
-shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=0)
-# log_changes = apply_shard_log_response(cache, [note_doc], shard_log)
-with client.subscribe_database(subscription_id="ui", schema_ids=["cultnet.interop-note"]) as live:
- initial_snapshot = live.read_next_snapshot_response()
-
-# The same raw-client request surface can ride RUDP when the peer advertises a
-# rudp:// endpoint and shares the connection id for that lane.
-rudp_client = CultMesh.create_client(
- endpoint="rudp://127.0.0.1:3076",
- connection_id=0x43554C54,
-)
-rudp_catalog = rudp_client.fetch_schema_catalog(kinds=["wire_message"])
-
-claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit")
-observation = simulation_observation(
- message_id="obs-1",
- witness_runtime_id="python-runtime",
- shard_id="interop",
- shard_epoch=1,
- frame=42,
- subject_id="player-1",
- claim_kind="hit",
- claim_hash=claim_hash,
-)
-typed_observation = CultNetSimulationObservation.from_wire(observation.to_wire())
-witness = witness_artifact_bundle(
- bundle_id="bundle-1",
- witness_kind="interop-proof",
- captured_at="2026-06-13T00:00:00Z",
- subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"},
- contracts=[{"role": "payload", "schemaId": "cultnet.interop-note"}],
- artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}],
- provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-runtime"},
-)
-typed_witness = CultNetWitnessArtifactBundle.from_wire(witness)
-witness_payload = typed_witness.to_payload()
-
-security = CultNetServerSecurityOptions.development()
-token = CultNetSecret.create_session_token(
- "318fb4b6-ff5e-4c4f-b911-d81807de53a8",
- datetime(2035, 1, 1, tzinfo=UTC),
- security,
- session_version=1,
-)
-session = CultNetSecret.try_validate_session_token(token, security)
-```
-
-AES-GCM string/byte encryption helpers are available through the same
-`CultNetSecret` surface when the optional `crypto` extra is installed.
-
-The peer can serve, dial, and probe the same raw-state interop lane used by the
-TypeScript, Rust, and C# test peers:
-
-```powershell
-python -m cultnet_py.interop_peer serve --runtime-id python-peer --runtime-kind python --display-name "Python Peer" --agent-id python-agent --advertise-host 127.0.0.1 --tcp-port 3075 --discovery-port 4075 --discovery-group 239.77.44.11 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json
-python -m cultnet_py.interop_peer dial --runtime-id python-client --runtime-kind python --display-name "Python Client" --agent-id python-client --target-host 127.0.0.1 --target-port 3075 --schema-path ..\cultnet-ts\integration\contracts\cultnet.interop-note.schema.json
-python -m cultnet_py.interop_peer probe --runtime-id python-prober --discovery-port 4075 --discovery-group 239.77.44.11
-```
-
-`cultmesh_py` includes a local cache-backed node, schema-v0 helpers for the
-CultMesh Verse catalog and peer exchange wire messages, local and verified
-authority lease checks, stream transport negotiation, committed simulation fact
-documents, and local prediction reconciliation:
-
-```python
-from datetime import UTC, datetime, timedelta
-
-from cultcache_py import define_database_entry_type
-from cultnet_py import (
- CultNetClientAuthorityScope,
- CultNetFileShardReplicaCursorStore,
- CultNetRawClient,
- CultNetSchemaShardLogFetcher,
- CultNetSchemaShardSnapshotFetcher,
- CultNetShardDescriptor,
- CultNetShardReplicator,
- CultNetShardReplicatorOptions,
-)
-from cultmesh_py import (
- CultMesh,
- CultMeshAuthorityLease,
- CultMeshGameSessionOptions,
- CultMeshHmacAuthorityLeaseVerifier,
- CultMeshNodeOptions,
- CultMeshSnapshotFanout,
- peer_exchange_request,
-)
-
-note_doc = define_database_entry_type(
- "mesh.note",
- [("body", 0), ("name", 1), ("kind", 2)],
- name="name",
- indexes={"kind": "kind"},
-)
-node = CultMesh.start_node(
- "mesh.cc",
- runtime_id="python-runtime",
- options=CultMeshNodeOptions(enable_durable_shard_logs=True),
-)
-node.database.register_document(note_doc)
-unsubscribe = node.database.watch_record(note_doc, "note:1", lambda change: print(change.change_kind))
-unsubscribe_named = node.database.watch_by_name(note_doc, "intro", lambda change: print(change.record_key))
-unsubscribe_kind = node.database.watch_by_index(note_doc, "kind", "guide", lambda change: print(change.record_key))
-node.database.put(note_doc, "note:1", {"body": "hello", "name": "intro", "kind": "guide"})
-live_note = node.database.get_required(note_doc, "note:1")
-put_message = node.database.put_raw_message(
- note_doc,
- "note:2",
- {"body": "wire me", "name": "wire", "kind": "interop"},
- shard_id="interop",
- shard_epoch=1,
-)
-snapshot_response = node.database.build_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id])
-snapshot_wire = node.database.create_snapshot_response(schema_ids=[note_doc.catalog_entry().schema_id])
-delete_message = node.database.delete_raw_message(note_doc, "note:2", shard_id="interop", shard_epoch=1)
-shard_log_response = node.database.build_shard_log_response(shard_id="interop", shard_epoch=1)
-shard_log_wire = node.database.create_shard_log_response(shard_id="interop", shard_epoch=1)
-
-station_stock_doc = define_database_entry_type(
- "aetheria.station_stock",
- [("missiles", 0), ("coolant", 1)],
- schema_id="gamecult.aetheria.station_stock.v1",
-)
-station_stock_ui_doc = define_database_entry_type(
- "aetheria.station_stock.ui",
- [("missiles", 0), ("coolant", 1)],
- schema_id="gamecult.aetheria.station_stock.v1",
-)
-node.database.register_document(station_stock_doc)
-node.database.put(station_stock_ui_doc, "station:starbridge:stock", {"missiles": 24, "coolant": 80})
-stock = node.database.get_required(station_stock_ui_doc, "station:starbridge:stock")
-
-peers = CultMesh.create_peer_catalog()
-response = peers.create_response(peer_exchange_request("pex-1", verse_id="local"))
-lease_verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"shared-lease-key"})
-lease_catalog = CultMesh.create_authority_lease_catalog(
- signature_verifier=lease_verifier.verify,
- require_verified_signatures=True,
-)
-lease_valid_from = datetime.now(UTC)
-lease = lease_verifier.issue(CultMeshAuthorityLease(
- lease_id="lease:python-runtime",
- verse_id="local",
- peer_id="python-runtime",
- roles=("shard-primary",),
- valid_from=lease_valid_from,
- expires_at=lease_valid_from + timedelta(minutes=5),
- shard_ids=("interop",),
- issuer_runtime_id="odin",
-))
-lease_catalog.upsert(lease)
-session = CultMesh.create_game_session(
- node,
- CultMeshGameSessionOptions(
- client_authority_scopes=(
- CultNetClientAuthorityScope("python-runtime", schema_ids=(note_doc.catalog_entry().schema_id,), key_prefix="input:python"),
- ),
- ),
-)
-server = CultMesh.serve_node(
- node,
- peer_catalog=peers,
- observation_hub=session.observation_hub,
- port=3075,
- max_snapshot_documents=1000,
- max_snapshot_bytes=4 * 1024 * 1024,
-)
-```
-
-The same local server can be launched as a package daemon when another runtime
-or operator process needs a CultNet endpoint without embedding Python glue:
-
-```powershell
-python -m cultmesh_py.daemon --runtime-id python-runtime --display-name "Python Runtime" --host 127.0.0.1 --port 3075 --cache-file mesh.cc --enable-durable-shard-logs --seed-interop-note --seed-shard-id interop --verse-id local --role shard-primary --ready-file mesh.ready.json
-```
-
-The ready file and stdout line are process readiness hints. The served state and
-capability truth still live on the framed CultNet/CultMesh endpoint. Use
-`--register-interop-note` to expose the package interop-note schema without
-seeding a note, or `--seed-interop-note` to register it and publish one local
-record for wire probes. Seeded records are committed through the raw mutation
-path, so peers can read them through both snapshot and shard-log catch-up. Add
-`--verse-id` and one or more `--role` values to publish the launched endpoint in
-its own Verse and peer catalogs for discovery clients. When the daemon is
-launched with `--cache-file`, `--enable-durable-shard-logs`, and
-`--shard-log-file`, a later daemon process can rehydrate and serve the persisted
-snapshot and shard log without reseeding.
-
-`CultMeshPeerHealthMonitor` probes peer-card endpoints with `cultnet.hello.v0`
-and preserves the runtime id, display name, document types, message versions,
-and mutation contracts reported by the peer.
-
-```python
-
-client = CultMesh.create_verse_discovery_client("127.0.0.1", 3075)
-verses = client.fetch_verses(transport_version="cultmesh.v0")
-mesh_peers = client.fetch_peers(verse_id="python-interop", roles=["read-replica"])
-client.sync_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"])
-client.fanout_peer_catalog(peers, verse_id="python-interop", roles=["read-replica"])
-replica_notes = client.sync_documents(
- node.database,
- peers,
- verse_id="python-interop",
- roles=["read-replica"],
- documents=[(note_doc, "note:remote")],
-)
-snapshot_fanout = CultMeshSnapshotFanout(
- client,
- node.database,
- peers,
- verse_id="python-interop",
- roles=["read-replica"],
- documents=[(note_doc, "note:remote")],
- on_document=lambda note: print(note),
-)
-snapshot_fanout.sync_once()
-
-raw_client = CultNetRawClient("127.0.0.1", 3075)
-synced_note = node.database.sync_document(raw_client, note_doc, "note:remote")
-synced_stock = CultMesh.sync_document(
- node,
- raw_client,
- station_stock_ui_doc,
- "station:starbridge:stock",
-)
-with CultMesh.subscribe_document(
- node,
- raw_client,
- station_stock_ui_doc,
- "station:starbridge:stock",
-) as stock_subscription:
- stock = stock_subscription.sync_initial()
- next_change = stock_subscription.read_next_change()
-node.database.sync_shard_log(raw_client, shard_id="interop", shard_epoch=1)
-replicator = CultNetShardReplicator(
- node.database,
- CultNetShardReplicatorOptions(
- fetcher=CultNetSchemaShardLogFetcher(),
- snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(),
- cursor_store=CultNetFileShardReplicaCursorStore("mesh.replica-cursors.msgpack"),
- poll_interval_seconds=1.0,
- on_error=lambda error: print(error),
- ),
-)
-replica_shard = CultNetShardDescriptor(
- shard_id="interop",
- owner_runtime_id="primary-runtime",
- epoch=1,
- schema_ids=(note_doc.catalog_entry().schema_id,),
- primary_endpoints=("cultnet://127.0.0.1:3075",),
-)
-replicator.pull_once(replica_shard)
-replicator.start([replica_shard])
-replicator.stop()
-server.stop()
-
-streams = CultMesh.create_stream_catalog()
-
-claim_hash = "accepted-claim-hash"
-facts = CultMesh.create_simulation_fact_committer(node)
-committed = facts.commit({
- "shardId": "arena",
- "shardEpoch": 4,
- "frame": 100,
- "subjectId": "bob",
- "claimKind": "hit",
- "claimHash": claim_hash,
- "witnessCount": 2,
- "supportWeight": 2.0,
- "totalWeight": 2.0,
- "confidence": 1.0,
- "hasQuorum": True,
-})
-session_commits = session.submit_and_commit(typed_observation)
-prediction = session.predict(note_doc, "input:python:move", {"body": "predicted input"})
-unsubscribe()
-```
-
## Wire Parity
-See [PARITY.md](PARITY.md) for the current evidence map, gates, and things this
-package still does not claim.
+See [docs/python-runtime-parity.md](../../docs/python-runtime-parity.md) for
+the evidence map across the three Python packages.
-The Python interop peer is `cultcache_py.interop`:
+The store interop peer is `cultcache_py.interop`:
```powershell
python -m cultcache_py.interop write --file cache.cc --runtime-id python
python -m cultcache_py.interop read --file cache.cc
```
-Current receipts:
+`packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared
+CultCache v1 parity matrix with TypeScript, Rust, and C#. `cultcache_py` ships
+a `py.typed` marker so downstream type checkers can inspect the package surface.
-- `packages/cultcache-ts/test/cult-cache.test.ts` includes Python in the shared
- CultCache v1 parity matrix with TypeScript, Rust, and C#.
-- `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` includes Python in
- the live TS/Rust/C#/Python schema-v0 peer ring: discovery, hello, schema
- catalog, wire-message catalog discovery, raw snapshot, raw document
- put/delete, mutation receipt, fire-command receipt, database subscription
- changes, shard catalog, shard log catch-up, simulation consensus candidates,
- committed simulation fact snapshots, Python session prediction reconciliation,
- and witness artifact bundle round-trips. The same test asks the Python peer
- for CultMesh Verse catalog and peer exchange responses over the CultNet pipe,
- then verifies the public `CultMeshDiscoveryClient` can fetch typed Python
- Verse and peer descriptors from that live endpoint. It also verifies
- `CultNetRawClient` can fetch the live Python peer's schema catalog, raw
- snapshot, and shard catalog.
-- `packages/cultcache-py/tests/test_cultcache.py` covers Python CultMesh
- Verse catalog, peer exchange, authority lease, stream negotiation, CultNet
- helper shapes, raw snapshot/shard-log application, simulation claim hashing,
- consensus aggregation, game-session fact commits, local prediction
- reconciliation, committed simulation fact payload slots, the local CultMesh
- server's TCP and RUDP schema request paths, and witness artifact bundle
- payload slots.
-- `cultcache_py`, `cultnet_py`, and `cultmesh_py` ship `py.typed` markers so
- downstream type checkers can inspect the package surface instead of treating
- the runtime as an untyped xenos swamp.
-
-## Performance Baseline
-
-The package ships a lightweight benchmark for Python-owned hot paths. In a
-CultLib source checkout, it can also run the C# reference benchmark beside it:
+## Tests
```powershell
-python -m cultcache_py.benchmark --records 1000 --json
-python -m cultcache_py.compare_csharp --records 1000 --json
-python -m cultcache_py.verify --json
+$env:PYTHONPATH="$PWD\packages\cultcache-py\src"
+python -m unittest discover -s packages\cultcache-py\tests
```
-It reports slot-indexed `DatabaseEntry` encode/decode throughput, framed
-CultNet MessagePack parse throughput, raw snapshot application into a
-registered cache, and public `CultCache` upsert/get throughput. The C# compare
-command also runs `packages/cultcache-py/tools/GameCult.Caching.Benchmark` and
-reports median Python-to-C# ratios for the shared public cache operations. It
-uses three samples by default; pass `--samples` when you need a different
-evidence shape. Both runtimes precompute record keys before measured cache
-upsert/get loops so the comparison is about public cache access, not string
-construction noise. The public in-memory cache owner stores records in
-type-partitioned dictionaries so hot `get(...)` calls avoid per-call
-composite-key allocation. Treat the benchmark numbers as a local baseline, not
-a claim that Python matches the C# reference in every workload.
+The suite runs with only this package on the path. If a test here needs
+`cultnet_py` or `cultmesh_py`, it belongs in that package.
diff --git a/packages/cultcache-py/pyproject.toml b/packages/cultcache-py/pyproject.toml
index 93ebf849..6c46d102 100644
--- a/packages/cultcache-py/pyproject.toml
+++ b/packages/cultcache-py/pyproject.toml
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "cultcache-py"
-version = "0.1.0"
-description = "Python CultCache, CultNet, and CultMesh runtime package for GameCult typed state."
+version = "0.3.0"
+description = "Python CultCache runtime for GameCult typed state."
readme = "README.md"
requires-python = ">=3.10"
-license = { text = "Private GameCult infrastructure" }
+license = "MIT"
authors = [{ name = "GameCult" }]
classifiers = [
"Programming Language :: Python :: 3",
@@ -17,23 +17,11 @@ classifiers = [
"Programming Language :: Python :: 3.12",
]
dependencies = ["msgpack>=1.0"]
-
-[project.optional-dependencies]
-msgpack = ["msgpack>=1.0"]
-crypto = ["cryptography>=42"]
-
[project.scripts]
cultcache-py-interop = "cultcache_py.interop:main"
-cultnet-py-interop = "cultnet_py.interop_peer:main"
-cultcache-py-benchmark = "cultcache_py.benchmark:main"
-cultcache-py-compare-csharp = "cultcache_py.compare_csharp:main"
-cultcache-py-verify = "cultcache_py.verify:main"
-cultmesh-py-daemon = "cultmesh_py.daemon:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
cultcache_py = ["py.typed"]
-cultnet_py = ["py.typed"]
-cultmesh_py = ["py.typed"]
diff --git a/packages/cultcache-py/src/cultcache_py/cache.py b/packages/cultcache-py/src/cultcache_py/cache.py
index f7ad1c12..0da2c04a 100644
--- a/packages/cultcache-py/src/cultcache_py/cache.py
+++ b/packages/cultcache-py/src/cultcache_py/cache.py
@@ -1,15 +1,52 @@
from __future__ import annotations
-from dataclasses import dataclass, field
-from typing import Any, Generic, TypeVar
+import functools
+import threading
+from dataclasses import dataclass, field, replace
+from typing import Any, Callable, Generic, TypeVar
from .backing_store import BackingStore, CultCacheEnvelope
from .documents import DocumentDefinition, extract_value
T = TypeVar("T")
+F = TypeVar("F", bound=Callable[..., Any])
GLOBAL_KEY = "__global__"
+def _locked(method: F) -> F:
+ """Runs a read holding the cache's lock. Reads are reentrant and may run inside a mutation."""
+
+ @functools.wraps(method)
+ def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any:
+ with self._lock:
+ return method(self, *args, **kwargs)
+
+ return wrapper # type: ignore[return-value]
+
+
+def _mutating(method: F) -> F:
+ """Runs an attach, pull, registration, write or delete holding the cache's lock, so its validation,
+ store I/O and apply cannot interleave with another thread's. One started on a thread that is already
+ inside a mutation of this cache (from a store call, decoder, encoder, extractor or updater) raises
+ before touching anything."""
+
+ @functools.wraps(method)
+ def wrapper(self: "CultCache", *args: Any, **kwargs: Any) -> Any:
+ if self._mutating_thread == threading.get_ident():
+ raise CultCacheError(
+ f"CultCache refuses a re-entrant {method.__name__}: a mutation cannot start from inside a store call, "
+ "decoder, encoder, extractor or updater of another mutation on the same cache"
+ )
+ with self._lock:
+ self._mutating_thread = threading.get_ident()
+ try:
+ return method(self, *args, **kwargs)
+ finally:
+ self._mutating_thread = None
+
+ return wrapper # type: ignore[return-value]
+
+
class CultCacheError(RuntimeError):
pass
@@ -20,12 +57,31 @@ class _State:
documents_by_schema_name: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
values: dict[str, dict[str, Any]] = field(default_factory=dict)
envelopes: dict[str, dict[str, CultCacheEnvelope]] = field(default_factory=dict)
- stores_by_type: dict[str, list[BackingStore]] = field(default_factory=dict)
- generic_stores: list[BackingStore] = field(default_factory=list)
+ stores_by_type: dict[str, BackingStore] = field(default_factory=dict)
+ generic_store: BackingStore | None = None
name_extractors: dict[str, str | Any] = field(default_factory=dict)
index_extractors: dict[str, dict[str, str | Any]] = field(default_factory=dict)
names: dict[tuple[str, str], str] = field(default_factory=dict)
indexes: dict[tuple[str, str, str], str] = field(default_factory=dict)
+ # The name and index values each held record produced when it was admitted. Removing a
+ # record reads these and runs no extractor, so removal has no fallible step.
+ lookup_keys: dict[str, dict[str, "_LookupKeys"]] = field(default_factory=dict)
+ # Load compatibility shim: a global persisted under a key other than GLOBAL_KEY is held as that
+ # type's global under GLOBAL_KEY, and its persisted key is kept here so the first write or delete
+ # of that global removes it from the home store. Remove once no store holds a global under a key
+ # other than GLOBAL_KEY.
+ legacy_global_keys: dict[str, str] = field(default_factory=dict)
+
+
+@dataclass(frozen=True)
+class _LookupKeys:
+ name: str | None
+ indexes: dict[str, str]
+
+
+_Names = dict[tuple[str, str], str]
+_Indexes = dict[tuple[str, str, str], str]
+_LookupKeysByType = dict[str, dict[str, _LookupKeys]]
class CultCacheBuilder:
@@ -69,46 +125,135 @@ class CultCache:
def __init__(self) -> None:
self._state = _State()
+ # Mutations hold this lock and refuse to start on the thread already inside one (see _mutating).
+ # It is reentrant so a mutation's extractors, decoders and updaters may still read the cache.
+ # Single-structure reads (get, get_all, get_envelope, key lookups) stay unlocked. The guarantee is
+ # per call: each reads one dict under the GIL and sees it wholly before or after a write's install.
+ # Two unlocked calls can straddle a write or pull, and see different states.
+ self._lock = threading.RLock()
+ self._mutating_thread: int | None = None
+ @_mutating
def register_document_type(self, document: DocumentDefinition[Any]) -> None:
+ self._register_document_type(document)
+
+ def _register_document_type(self, document: DocumentDefinition[Any]) -> None:
if document.type in self._state.documents:
raise CultCacheError(f"Document type already registered: {document.type}")
schema_name = document.catalog_entry().schema_name
if schema_name in self._state.documents_by_schema_name:
raise CultCacheError(f"Document schema name already registered: {schema_name}")
+ name_extractors = dict(self._state.name_extractors)
+ index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
+ if document.name is not None:
+ name_extractors[document.type] = document.name
+ if document.indexes:
+ index_extractors.setdefault(document.type, {}).update(document.indexes)
+ self._install_lookups(name_extractors, index_extractors)
self._state.documents[document.type] = document
self._state.documents_by_schema_name[schema_name] = document
- if document.name is not None:
- self.register_name_lookup(document, document.name)
- for index, extractor in document.indexes.items():
- self.register_index(document, index, extractor)
+ @_mutating
def register_registry(self, documents: list[DocumentDefinition[Any]] | tuple[DocumentDefinition[Any], ...]) -> None:
for document in documents:
- self.register_document_type(document)
+ self._register_document_type(document)
+ @_mutating
def register_name_lookup(self, document: DocumentDefinition[Any], extractor: str | Any) -> None:
self._assert_registered(document)
- self._state.name_extractors[document.type] = extractor
- self._rebuild_indexes()
+ name_extractors = dict(self._state.name_extractors)
+ name_extractors[document.type] = extractor
+ self._install_lookups(name_extractors, self._state.index_extractors)
+ @_mutating
def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None:
self._assert_registered(document)
- self._state.index_extractors.setdefault(document.type, {})[index] = extractor
- self._rebuild_indexes()
+ index_extractors = {type: dict(indexes) for type, indexes in self._state.index_extractors.items()}
+ index_extractors.setdefault(document.type, {})[index] = extractor
+ self._install_lookups(self._state.name_extractors, index_extractors)
+ def _install_lookups(
+ self, name_extractors: dict[str, str | Any], index_extractors: dict[str, dict[str, str | Any]]
+ ) -> None:
+ """Derives every lookup under the candidate extractors, then installs extractors and lookups
+ together. An extractor that raises on a held value installs nothing."""
+ names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors)
+ (
+ self._state.name_extractors,
+ self._state.index_extractors,
+ self._state.names,
+ self._state.indexes,
+ self._state.lookup_keys,
+ ) = (name_extractors, index_extractors, names, indexes, lookup_keys)
+
+ @_mutating
def add_backing_store(self, store: BackingStore, types: list[str] | tuple[str, ...] | set[str]) -> None:
+ """Makes the store home to the given types, or the generic store when there are none.
+
+ A type has exactly one home store, and attaching cannot move the home of a record the cache holds.
+ """
+ types = list(types)
+ if not types:
+ self._add_generic_store(store)
+ return
for type in types:
- self._state.stores_by_type.setdefault(type, []).append(store)
+ if type in self._state.stores_by_type:
+ raise CultCacheError(
+ f"Document type {type} is already routed to another backing store; it cannot also route to this one"
+ )
+ routes = dict(self._state.stores_by_type)
+ routes.update({type: store for type in types})
+ self._refuse_home_moves(routes, self._state.generic_store)
+ self._state.stores_by_type = routes
+ @_mutating
def add_generic_store(self, store: BackingStore) -> None:
- self._state.generic_stores.append(store)
+ """Makes the store home to every type no other store claims. A cache has at most one."""
+ self._add_generic_store(store)
+ def _add_generic_store(self, store: BackingStore) -> None:
+ if self._state.generic_store is not None:
+ raise CultCacheError(
+ "Backing store would be a second generic store; name the types it is home to"
+ )
+ self._refuse_home_moves(self._state.stores_by_type, store)
+ self._state.generic_store = store
+
+ def _refuse_home_moves(self, routes: dict[str, BackingStore], generic: BackingStore | None) -> None:
+ for type, values in self._state.values.items():
+ if not values:
+ continue
+ before = self._state.stores_by_type.get(type, self._state.generic_store)
+ after = routes.get(type, generic)
+ if before is not after:
+ raise CultCacheError(
+ f"Attaching this backing store would move {type} from {self._describe(before, routes, generic)} "
+ f"to {self._describe(after, routes, generic)}; attach routed stores before the generic store"
+ )
+
+ @staticmethod
+ def _describe(store: BackingStore | None, routes: dict[str, BackingStore], generic: BackingStore | None) -> str:
+ if store is None:
+ return "memory"
+ if store is generic:
+ return "the generic store"
+ return "the store routed to " + ", ".join(sorted(type for type, routed in routes.items() if routed is store))
+
+ @_mutating
def pull_all_backing_stores(self) -> None:
- self._state.values.clear()
- self._state.envelopes.clear()
+ """Builds the complete next state (values, envelopes, name and index lookups) in local
+ structures, running every check and every user extractor; the cache's state is replaced
+ only once that build finishes, so a refused load admits nothing."""
+ loaded_values: dict[str, dict[str, Any]] = {}
+ loaded_envelopes: dict[str, dict[str, CultCacheEnvelope]] = {}
+ loaded_legacy_global_keys: dict[str, str] = {}
+ routes, generic = self._state.stores_by_type, self._state.generic_store
seen_globals: set[str] = set()
- for store in [*self._all_specific_stores(), *self._state.generic_stores]:
+ stores: list[BackingStore] = []
+ for candidate in [*self._state.stores_by_type.values(), self._state.generic_store]:
+ if candidate is not None and all(candidate is not store for store in stores):
+ stores.append(candidate)
+ for store in stores:
for envelope in store.pull_all():
document = self._resolve_document_for_envelope(envelope)
if document is None:
@@ -122,14 +267,34 @@ class CultCache:
schema_id=envelope.schema_id,
catalog_entry=envelope.catalog_entry,
)
+ home = routes.get(envelope.type, generic)
+ if home is not store:
+ raise CultCacheError(
+ f"{envelope.type} record {envelope.key} was loaded from {self._describe(store, routes, generic)}, "
+ f"but its home is {self._describe(home, routes, generic)}"
+ )
if document.global_document:
- if envelope.type in seen_globals and envelope.key == GLOBAL_KEY:
+ if envelope.type in seen_globals:
raise CultCacheError(f"Duplicate global document for type: {envelope.type}")
seen_globals.add(envelope.type)
+ if envelope.key != GLOBAL_KEY:
+ loaded_legacy_global_keys[envelope.type] = envelope.key
+ envelope = replace(envelope, key=GLOBAL_KEY)
value = document.decode_payload(envelope.payload)
- self._state.values.setdefault(envelope.type, {})[envelope.key] = value
- self._state.envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
- self._rebuild_indexes()
+ loaded_values.setdefault(envelope.type, {})[envelope.key] = value
+ loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
+ loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups(
+ loaded_values, self._state.name_extractors, self._state.index_extractors
+ )
+ # Plain attribute assignments: nothing fallible runs once the swap begins.
+ (
+ self._state.values,
+ self._state.envelopes,
+ self._state.names,
+ self._state.indexes,
+ self._state.lookup_keys,
+ self._state.legacy_global_keys,
+ ) = (loaded_values, loaded_envelopes, loaded_names, loaded_indexes, loaded_lookup_keys, loaded_legacy_global_keys)
def get(self, document: DocumentDefinition[T], key: str) -> T | None:
self._assert_registered(document)
@@ -169,6 +334,9 @@ class CultCache:
self._assert_registered(document)
return self._state.names.get((document.type, name))
+ # Name and index resolution read the lookup and then the values, two structures a pull swaps and a
+ # write updates in sequence, so they hold the lock to resolve a key and its value from one state.
+ @_locked
def get_by_name(self, document: DocumentDefinition[T], name: str) -> T | None:
key = self.get_key_by_name(document, name)
return None if key is None else self.get(document, key)
@@ -177,17 +345,19 @@ class CultCache:
self._assert_registered(document)
return self._state.indexes.get((document.type, index, value))
+ @_locked
def get_by_index(self, document: DocumentDefinition[T], index: str, value: str) -> T | None:
key = self.get_key_by_index(document, index, value)
return None if key is None else self.get(document, key)
+ @_mutating
def put(self, document: DocumentDefinition[T], key: str, value: T) -> None:
+ self._put(document, key, value)
+
+ def _put(self, document: DocumentDefinition[T], key: str, value: T) -> None:
self._assert_registered(document)
if document.global_document and key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
- values = self._state.values.setdefault(document.type, {})
- envelopes = self._state.envelopes.setdefault(document.type, {})
- old_value = values.get(key)
catalog_entry = document.catalog_entry()
envelope = CultCacheEnvelope.create(
key=key,
@@ -196,100 +366,139 @@ class CultCache:
schema_id=catalog_entry.schema_id,
catalog_entry=catalog_entry,
)
- stores = self._stores_for_type(document.type)
- for store in stores:
- store.push(envelope)
- if old_value is not None:
- self._remove_value_indexes(document.type, key, old_value)
- values[key] = value
- envelopes[key] = envelope
- self._add_value_indexes(document.type, key, value)
+ self._write(document, [(envelope, value)], batch=False)
+ @_mutating
def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T:
self._assert_registered(document)
- if envelope.type != document.type:
- raise CultCacheError(
- f"Envelope type {envelope.type} does not match document type {document.type}"
- )
- values = self._state.values.setdefault(document.type, {})
- envelopes = self._state.envelopes.setdefault(document.type, {})
- old_value = values.get(envelope.key)
+ self._check_envelope(document, envelope)
value = document.decode_payload(envelope.payload)
- for store in self._stores_for_type(document.type):
- store.push(envelope)
- if old_value is not None:
- self._remove_value_indexes(document.type, envelope.key, old_value)
- values[envelope.key] = value
- envelopes[envelope.key] = envelope
- self._add_value_indexes(document.type, envelope.key, value)
+ self._write(document, [(envelope, value)], batch=False)
return value
+ @_mutating
def put_envelopes(self, document: DocumentDefinition[T], envelopes: list[CultCacheEnvelope]) -> list[T]:
self._assert_registered(document)
values: list[T] = []
for envelope in envelopes:
- if envelope.type != document.type:
- raise CultCacheError(
- f"Envelope type {envelope.type} does not match document type {document.type}"
- )
- if document.global_document and envelope.key != GLOBAL_KEY:
- raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
+ self._check_envelope(document, envelope)
values.append(document.decode_payload(envelope.payload))
-
- stores = self._stores_for_type(document.type)
- for store in stores:
- store.push_all(envelopes)
- values_by_key = self._state.values.setdefault(document.type, {})
- envelopes_by_key = self._state.envelopes.setdefault(document.type, {})
- for envelope, value in zip(envelopes, values):
- old_value = values_by_key.get(envelope.key)
- if old_value is not None:
- self._remove_value_indexes(document.type, envelope.key, old_value)
- values_by_key[envelope.key] = value
- envelopes_by_key[envelope.key] = envelope
- self._add_value_indexes(document.type, envelope.key, value)
+ self._write(document, list(zip(envelopes, values)), batch=True)
return values
+ @staticmethod
+ def _check_envelope(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> None:
+ if envelope.type != document.type:
+ raise CultCacheError(
+ f"Envelope type {envelope.type} does not match document type {document.type}"
+ )
+ if not envelope.key or not envelope.key.strip():
+ raise CultCacheError(f"Envelope key for document type {document.type} must be non-empty")
+ if document.global_document and envelope.key != GLOBAL_KEY:
+ raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
+
+ def _write(
+ self, document: DocumentDefinition[Any], records: list[tuple[CultCacheEnvelope, Any]], *, batch: bool
+ ) -> None:
+ """Validates every record (home and name and index extractors included) before the store is
+ touched; once the store accepts, applying to the cache has no fallible step."""
+ type = document.type
+ name_extractor = self._state.name_extractors.get(type)
+ index_extractors = self._state.index_extractors.get(type, {})
+ admitted = [
+ (envelope, value, self._lookup_keys_for(value, name_extractor, index_extractors))
+ for envelope, value in records
+ ]
+ store = self._store_for_type(type)
+ legacy_key = self._state.legacy_global_keys.get(type)
+ if store is not None:
+ if batch:
+ store.push_all([envelope for envelope, _ in records])
+ else:
+ store.push(records[0][0])
+ if legacy_key is not None:
+ self._retire_legacy_global(store, type, legacy_key)
+ self._state.legacy_global_keys.pop(type, None)
+ values = self._state.values.setdefault(type, {})
+ envelopes = self._state.envelopes.setdefault(type, {})
+ lookup_keys = self._state.lookup_keys.setdefault(type, {})
+ for envelope, value, keys in admitted:
+ self._remove_lookups(type, envelope.key)
+ values[envelope.key] = value
+ envelopes[envelope.key] = envelope
+ lookup_keys[envelope.key] = keys
+ if keys.name is not None:
+ self._state.names[(type, keys.name)] = envelope.key
+ for index, index_value in keys.indexes.items():
+ self._state.indexes[(type, index, index_value)] = envelope.key
+
+ @staticmethod
+ def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None:
+ """The first write of a global loaded from a legacy key removes that record in the same write. If
+ the removal fails, the new record is taken back out, so the store keeps only the legacy record."""
+ try:
+ store.delete(type, legacy_key)
+ except BaseException:
+ try:
+ store.delete(type, GLOBAL_KEY)
+ except Exception: # noqa: BLE001 - the removal failure is the error reported
+ pass
+ raise
+
+ @_mutating
def put_global(self, document: DocumentDefinition[T], value: T) -> None:
self._assert_global(document)
- self.put(document, GLOBAL_KEY, value)
+ self._put(document, GLOBAL_KEY, value)
+ @_mutating
def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
+ return self._update(document, key, updater)
+
+ def _update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
current = self.get_required(document, key)
updated = updater(current)
- self.put(document, key, updated)
+ self._put(document, key, updated)
return updated
+ @_mutating
def update_global(self, document: DocumentDefinition[T], updater: Any) -> T:
self._assert_global(document)
- return self.update(document, GLOBAL_KEY, updater)
+ return self._update(document, GLOBAL_KEY, updater)
+ @_mutating
def delete(self, document: DocumentDefinition[Any], key: str) -> None:
- self._assert_registered(document)
- for store in self._stores_for_type(document.type):
- store.delete(document.type, key)
- values = self._state.values.get(document.type)
- old_value = None if values is None else values.pop(key, None)
- if values == {}:
- self._state.values.pop(document.type, None)
- envelopes = self._state.envelopes.get(document.type)
- if envelopes is not None:
- envelopes.pop(key, None)
- if envelopes == {}:
- self._state.envelopes.pop(document.type, None)
- if old_value is not None:
- self._remove_value_indexes(document.type, key, old_value)
+ self._delete(document, key)
+ def _delete(self, document: DocumentDefinition[Any], key: str) -> None:
+ self._assert_registered(document)
+ store = self._store_for_type(document.type)
+ legacy_key = self._state.legacy_global_keys.get(document.type) if key == GLOBAL_KEY else None
+ if store is not None:
+ store.delete(document.type, key if legacy_key is None else legacy_key)
+ if legacy_key is not None:
+ del self._state.legacy_global_keys[document.type]
+ self._remove_lookups(document.type, key)
+ for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys):
+ held = by_type.get(document.type)
+ if held is not None:
+ held.pop(key, None)
+ if not held:
+ by_type.pop(document.type, None)
+
+ @_mutating
def delete_global(self, document: DocumentDefinition[Any]) -> None:
self._assert_global(document)
- self.delete(document, GLOBAL_KEY)
+ self._delete(document, GLOBAL_KEY)
+ # Snapshots iterate dicts a concurrent write would resize mid-iteration, so they hold the lock.
+ @_locked
def snapshot(self) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for type, values in self._state.values.items():
out[type] = dict(values)
return out
+ @_locked
def snapshot_envelopes(self) -> list[CultCacheEnvelope]:
return [
envelope
@@ -297,75 +506,70 @@ class CultCache:
for envelope in envelopes.values()
]
- def _stores_for_type(self, type: str) -> list[BackingStore]:
- specific = self._state.stores_by_type.get(type)
- return specific if specific else self._state.generic_stores
+ def _store_for_type(self, type: str) -> BackingStore | None:
+ """The store that claims the type, else the generic store.
- def _all_specific_stores(self) -> list[BackingStore]:
- stores: list[BackingStore] = []
- seen: set[int] = set()
- for routed in self._state.stores_by_type.values():
- for store in routed:
- marker = id(store)
- if marker not in seen:
- stores.append(store)
- seen.add(marker)
- return stores
+ Returns None only for a cache with no stores, which is an in-memory cache.
+ """
+ store = self._state.stores_by_type.get(type, self._state.generic_store)
+ if store is None and self._state.stores_by_type:
+ raise CultCacheError(f"No backing store is home to document type: {type}")
+ return store
- def _rebuild_indexes(self) -> None:
- self._state.names.clear()
- self._state.indexes.clear()
- for type, values in self._state.values.items():
+ @staticmethod
+ def _lookup_keys_for(
+ value: Any, name_extractor: str | Any | None, index_extractors: dict[str, str | Any]
+ ) -> _LookupKeys:
+ name = None if name_extractor is None else extract_value(value, name_extractor)
+ indexes: dict[str, str] = {}
+ for index, extractor in index_extractors.items():
+ index_value = extract_value(value, extractor)
+ if index_value is not None:
+ indexes[index] = str(index_value)
+ return _LookupKeys(None if name is None else str(name), indexes)
+
+ def _derive_lookups(
+ self,
+ values_by_type: dict[str, dict[str, Any]],
+ name_extractors: dict[str, str | Any],
+ index_extractors: dict[str, dict[str, str | Any]],
+ ) -> tuple[_Names, _Indexes, _LookupKeysByType]:
+ """Builds name and index lookups for the given values in fresh dicts, touching no cache state."""
+ names: _Names = {}
+ indexes: _Indexes = {}
+ lookup_keys: _LookupKeysByType = {}
+ for type, values in values_by_type.items():
+ name_extractor = name_extractors.get(type)
+ type_index_extractors = index_extractors.get(type, {})
for key, value in values.items():
- self._add_value_indexes(type, key, value)
+ keys = self._lookup_keys_for(value, name_extractor, type_index_extractors)
+ lookup_keys.setdefault(type, {})[key] = keys
+ if keys.name is not None:
+ names[(type, keys.name)] = key
+ for index, index_value in keys.indexes.items():
+ indexes[(type, index, index_value)] = key
+ return names, indexes, lookup_keys
- def _add_value_indexes(self, type: str, key: str, value: Any) -> None:
- name_extractor = self._state.name_extractors.get(type)
- if name_extractor is not None:
- name = extract_value(value, name_extractor)
- if name is not None:
- self._state.names[(type, str(name))] = key
- for index, extractor in self._state.index_extractors.get(type, {}).items():
- index_value = extract_value(value, extractor)
- if index_value is not None:
- self._state.indexes[(type, index, str(index_value))] = key
-
- def _remove_value_indexes(self, type: str, key: str, value: Any) -> None:
- name_extractor = self._state.name_extractors.get(type)
- if name_extractor is not None:
- name = extract_value(value, name_extractor)
- if name is not None:
- self._remove_name_index(type, key, str(name))
- for index, extractor in self._state.index_extractors.get(type, {}).items():
- index_value = extract_value(value, extractor)
- if index_value is not None:
- self._remove_secondary_index(type, key, index, str(index_value))
-
- def _remove_name_index(self, type: str, key: str, name: str) -> None:
- lookup_key = (type, name)
- if self._state.names.get(lookup_key) != key:
+ def _remove_lookups(self, type: str, key: str) -> None:
+ """Removes the lookups the held record produced at admission, handing a name or index value to
+ another held record that produced it too. Reads stored lookup keys only; runs no extractor."""
+ held = self._state.lookup_keys.get(type, {})
+ keys = held.get(key)
+ if keys is None:
return
- self._state.names.pop(lookup_key, None)
- name_extractor = self._state.name_extractors.get(type)
- if name_extractor is None:
- return
- for candidate_key, candidate in self._state.values.get(type, {}).items():
- candidate_name = extract_value(candidate, name_extractor)
- if candidate_key != key and candidate_name is not None and str(candidate_name) == name:
- self._state.names[lookup_key] = candidate_key
-
- def _remove_secondary_index(self, type: str, key: str, index: str, value: str) -> None:
- lookup_key = (type, index, value)
- if self._state.indexes.get(lookup_key) != key:
- return
- self._state.indexes.pop(lookup_key, None)
- extractor = self._state.index_extractors.get(type, {}).get(index)
- if extractor is None:
- return
- for candidate_key, candidate in self._state.values.get(type, {}).items():
- candidate_value = extract_value(candidate, extractor)
- if candidate_key != key and candidate_value is not None and str(candidate_value) == value:
- self._state.indexes[lookup_key] = candidate_key
+ if keys.name is not None and self._state.names.get((type, keys.name)) == key:
+ del self._state.names[(type, keys.name)]
+ for candidate_key, candidate in held.items():
+ if candidate_key != key and candidate.name == keys.name:
+ self._state.names[(type, keys.name)] = candidate_key
+ for index, index_value in keys.indexes.items():
+ lookup_key = (type, index, index_value)
+ if self._state.indexes.get(lookup_key) != key:
+ continue
+ del self._state.indexes[lookup_key]
+ for candidate_key, candidate in held.items():
+ if candidate_key != key and candidate.indexes.get(index) == index_value:
+ self._state.indexes[lookup_key] = candidate_key
def _assert_registered(self, document: DocumentDefinition[Any]) -> None:
if self._state.documents.get(document.type) is not document:
diff --git a/packages/cultcache-py/src/cultmesh_py/simulation.py b/packages/cultcache-py/src/cultmesh_py/simulation.py
deleted file mode 100644
index 4b727fd5..00000000
--- a/packages/cultcache-py/src/cultmesh_py/simulation.py
+++ /dev/null
@@ -1,135 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from datetime import UTC, datetime
-import hashlib
-from typing import Any
-
-from cultcache_py import define_database_entry_type
-from cultcache_py.documents import DocumentDefinition
-
-from .node import CultMeshNode
-
-SIMULATION_FACT_DOCUMENT_TYPE = "gamecult.mesh.simulation_fact"
-SIMULATION_FACT_SCHEMA_VERSION = "gamecult.mesh.simulation_fact.v1"
-
-
-@dataclass(frozen=True)
-class CultMeshSimulationFact:
- fact_id: str
- shard_id: str
- shard_epoch: int
- frame: int
- subject_id: str
- claim_kind: str
- claim_hash: str
- claim_summary: str | None
- witness_count: int
- support_weight: float
- total_weight: float
- confidence: float
- committed_at: str
-
- @staticmethod
- def compute_fact_id(candidate: dict[str, Any]) -> str:
- canonical = "\x1f".join([
- _candidate_string(candidate, "shardId", "shard_id"),
- str(_candidate_int(candidate, "shardEpoch", "shard_epoch")),
- str(_candidate_int(candidate, "frame", "frame")),
- _candidate_string(candidate, "subjectId", "subject_id"),
- _candidate_string(candidate, "claimKind", "claim_kind"),
- ])
- return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
-
- @staticmethod
- def create_record_key(candidate: dict[str, Any]) -> str:
- return f"simulation:{CultMeshSimulationFact.compute_fact_id(candidate)}"
-
- @staticmethod
- def from_candidate(candidate: dict[str, Any], *, committed_at: str | None = None) -> "CultMeshSimulationFact":
- return CultMeshSimulationFact(
- fact_id=CultMeshSimulationFact.compute_fact_id(candidate),
- shard_id=_candidate_string(candidate, "shardId", "shard_id"),
- shard_epoch=_candidate_int(candidate, "shardEpoch", "shard_epoch"),
- frame=_candidate_int(candidate, "frame", "frame"),
- subject_id=_candidate_string(candidate, "subjectId", "subject_id"),
- claim_kind=_candidate_string(candidate, "claimKind", "claim_kind"),
- claim_hash=_candidate_string(candidate, "claimHash", "claim_hash"),
- claim_summary=_candidate_optional_string(candidate, "claimSummary", "claim_summary"),
- witness_count=_candidate_int(candidate, "witnessCount", "witness_count"),
- support_weight=_candidate_float(candidate, "supportWeight", "support_weight"),
- total_weight=_candidate_float(candidate, "totalWeight", "total_weight"),
- confidence=_candidate_float(candidate, "confidence", "confidence"),
- committed_at=committed_at or datetime.now(UTC).isoformat(),
- )
-
-
-simulation_fact_document: DocumentDefinition[CultMeshSimulationFact] = define_database_entry_type(
- SIMULATION_FACT_DOCUMENT_TYPE,
- [
- ("fact_id", 0),
- ("shard_id", 1),
- ("shard_epoch", 2),
- ("frame", 3),
- ("subject_id", 4),
- ("claim_kind", 5),
- ("claim_hash", 6),
- ("claim_summary", 7, None),
- ("witness_count", 8),
- ("support_weight", 9),
- ("total_weight", 10),
- ("confidence", 11),
- ("committed_at", 12),
- ],
- cls=CultMeshSimulationFact,
- name="fact_id",
- indexes={"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"},
- schema_name=SIMULATION_FACT_DOCUMENT_TYPE,
- schema_version=SIMULATION_FACT_SCHEMA_VERSION,
-)
-
-
-@dataclass(frozen=True)
-class CultMeshSimulationFactCommit:
- key: str
- fact: CultMeshSimulationFact
-
-
-@dataclass
-class CultMeshSimulationFactCommitter:
- node: CultMeshNode
- document: DocumentDefinition[CultMeshSimulationFact] = simulation_fact_document
-
- def __post_init__(self) -> None:
- if all(document.type != self.document.type for document in self.node.documents):
- self.node.register_document(self.document)
-
- def commit(self, candidate: dict[str, Any], *, committed_at: str | None = None) -> CultMeshSimulationFactCommit:
- if not bool(candidate.get("hasQuorum") if "hasQuorum" in candidate else candidate.get("has_quorum")):
- raise ValueError("Simulation candidate cannot be committed before quorum")
- key = CultMeshSimulationFact.create_record_key(candidate)
- fact = CultMeshSimulationFact.from_candidate(candidate, committed_at=committed_at)
- self.node.put(self.document, key, fact)
- return CultMeshSimulationFactCommit(key=key, fact=fact)
-
-
-def _candidate_string(candidate: dict[str, Any], camel: str, snake: str) -> str:
- value = candidate.get(camel, candidate.get(snake, ""))
- if value is None:
- return ""
- return str(value)
-
-
-def _candidate_optional_string(candidate: dict[str, Any], camel: str, snake: str) -> str | None:
- value = candidate.get(camel, candidate.get(snake))
- if value is None:
- return None
- return str(value)
-
-
-def _candidate_int(candidate: dict[str, Any], camel: str, snake: str) -> int:
- return int(candidate.get(camel, candidate.get(snake, 0)))
-
-
-def _candidate_float(candidate: dict[str, Any], camel: str, snake: str) -> float:
- return float(candidate.get(camel, candidate.get(snake, 0.0)))
diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py
index 3ecb763f..ac81cc6d 100644
--- a/packages/cultcache-py/tests/test_cultcache.py
+++ b/packages/cultcache-py/tests/test_cultcache.py
@@ -1,23 +1,12 @@
-from __future__ import annotations
+from __future__ import annotations
-import json
-import os
-import socket
-import subprocess
-import sys
import tempfile
import threading
-import time
import unittest
-import hashlib
-import hmac
-import io
-from dataclasses import asdict, dataclass
-from datetime import UTC, datetime, timedelta
+from dataclasses import asdict, dataclass, replace
from pathlib import Path
-from typing import Callable
-from uuid import uuid4
-
+from cultcache_py.backing_store import CultCacheEnvelope
+from cultcache_py.cache import CultCacheError
from cultcache_py import (
CultCache,
JsonLinesBackingStore,
@@ -25,155 +14,7 @@ from cultcache_py import (
define_database_entry_type,
define_document_type,
)
-from cultcache_py.benchmark import run_benchmark
-from cultcache_py.compare_csharp import (
- DEFAULT_PARITY_THRESHOLD,
- DEFAULT_SAMPLE_COUNT,
- _compare_common_metrics,
- _median_result,
- _parity_status,
-)
-from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document, read_note, write_note
-from cultcache_py.verify import verify
-from cultnet_py.interop_peer import append_shard_log_put, build_state, raw_snapshot_response
-from cultnet_py import (
- compute_simulation_claim_hash,
- CultNetDatabaseChange,
- CultNetClientAuthorityScope,
- CultNetRawClient,
- CultNetRawDocumentRecord,
- CultNetRawSnapshotResponse,
- CultNetSchemaCatalog,
- CultNetSchemaDescriptor,
- CultNetShardCatalog,
- CultNetShardDescriptor,
- CultNetShardLogEntry,
- CultNetShardLogResponse,
- CultNetFileShardReplicaCursorStore,
- CultNetFileShardMutationLogStore,
- CultNetInMemoryShardReplicaCursorStore,
- CultNetSchemaWriteForwarder,
- CultNetSchemaShardLogFetcher,
- CultNetSchemaShardSnapshotFetcher,
- CultNetPeerError,
- CultNetClientSecurityOptions,
- CultNetSecret,
- CultNetServerSecurityOptions,
- CultNetShardReplicator,
- CultNetShardReplicatorOptions,
- CultNetShardMutationLogStore,
- CultNetShardWriteForwarder,
- CultNetSimulationConsensusOptions,
- CultNetSimulationConsensusCandidate,
- CultNetSimulationObservation,
- CultNetSimulationObservationHub,
- CultNetRudpPacket,
- CultNetRudpPacketType,
- CultNetRudpSendOptions,
- CultNetRudpSession,
- CultNetRudpSessionOptions,
- CultNetRudpSocketMode,
- CultNetRudpReconnectLoop,
- CultNetRudpSocketTransportConnection,
- CultNetRudpSocketTransportOptions,
- TcpFramedTransportConnection,
- CultNetWitnessArtifactBundle,
- apply_raw_document_record,
- apply_raw_snapshot,
- apply_shard_log_response,
- create_rudp_transport_profile,
- create_tcp_framed_transport_profile,
- create_reconnect_policy,
- CultNetReconnectController,
- database_subscribe,
- database_unsubscribe,
- decode_frame,
- decode_rudp_packet,
- decode_witness_artifact_bundle_payload,
- document_delete,
- document_put_raw,
- encode_frame,
- encode_rudp_packet,
- encode_witness_artifact_bundle_payload,
- hello,
- login,
- login_success,
- parse_message,
- register,
- schema_catalog_request,
- schema_document_map,
- shard_catalog_request,
- shard_log_request,
- simulation_observation,
- snapshot_request,
- verify_session,
- wire_message_schema_catalog,
- wire_message_schema_descriptors,
- witness_artifact_bundle,
- compute_reconnect_delay_ms,
-)
-from cultmesh_py import create_node
-from cultmesh_py import (
- CultMesh,
- CultMeshDatabase,
- CultMeshDatabaseChange,
- CultMeshGameSessionOptions,
- CultMeshNodeOptions,
- CultMeshReactiveDocumentOptions,
- CultMeshPeerCard,
- CultMeshPeerCatalog,
- CultMeshSimulationFact,
- CultMeshAuthorityLease,
- CultMeshAuthorityLeaseCatalog,
- CultMeshHmacAuthorityLeaseVerifier,
- CultMeshDiscoveryClient,
- CultMeshPeerExchangeClient,
- CultMeshPeerHealthMonitor,
- CultMeshSimulationObservationFanout,
- CultMeshSnapshotFanout,
- CultMeshStreamCatalog,
- CultMeshStreamConsumerProfile,
- CultMeshStreamDescriptor,
- CultMeshStreamFrameHandle,
- CultMeshVerseCatalog,
- CultMeshVerseCompatibility,
- CultMeshVerseDescriptor,
- CultMeshVerseDiscoveryClient,
- READY_SCHEMA_VERSION,
- peer_exchange_request,
- simulation_fact_document,
- verse_catalog_request,
-)
-
-
-def bind_udp_socket() -> socket.socket:
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- sock.bind(("127.0.0.1", 0))
- sock.settimeout(0.02)
- return sock
-
-
-def pump_rudp_handshake(
- client: CultNetRudpSocketTransportConnection,
- server: CultNetRudpSocketTransportConnection,
-) -> None:
- for _ in range(20):
- server.receive_once()
- client.receive_once()
- server.receive_once()
- if client.connected and server.connected:
- return
- time.sleep(0.005)
- raise AssertionError("RUDP socket handshake did not complete")
-
-
-def receive_rudp_frame(transport: CultNetRudpSocketTransportConnection):
- for _ in range(20):
- frame = transport.receive_once()
- if frame is not None:
- return frame
- time.sleep(0.005)
- raise AssertionError("RUDP socket frame was not delivered")
+from cultcache_py.interop import read_note, write_note
@dataclass
@@ -194,14 +35,6 @@ def item_doc():
class CultCacheTests(unittest.TestCase):
- def _wait_until(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None:
- deadline = time.monotonic() + timeout_seconds
- while time.monotonic() < deadline:
- if predicate():
- return
- time.sleep(0.01)
- self.fail("Timed out waiting for condition")
-
def test_round_trips_registered_documents(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
document = item_doc()
@@ -408,100 +241,549 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(loaded["authorRuntimeId"], "python-test")
self.assertIn("interop", loaded["tags"])
- def test_benchmark_reports_core_hot_path_metrics(self) -> None:
- result = run_benchmark(8)
- metric_names = {metric["name"] for metric in result["metrics"]}
- self.assertEqual(result["records"], 8)
- self.assertEqual(result["wireMessageCount"], 8)
- self.assertEqual(metric_names, {
- "database_entry_encode",
- "database_entry_decode",
- "cultnet_frame_parse",
- "raw_snapshot_apply",
- "cache_upsert",
- "cache_get",
- })
- self.assertTrue(all(metric["opsPerSecond"] > 0 for metric in result["metrics"]))
+ def test_second_generic_store_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ cache = CultCache()
+ cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"))
+ with self.assertRaisesRegex(CultCacheError, "second generic store"):
+ cache.add_generic_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"))
- def test_compare_csharp_median_result_summarizes_samples(self) -> None:
- self.assertEqual(DEFAULT_SAMPLE_COUNT, 3)
- self.assertEqual(DEFAULT_PARITY_THRESHOLD, 0.90)
- samples = [
- {
- "runtime": "python",
- "records": 3,
- "metrics": [
- {"name": "cache_get", "operations": 3, "elapsedMs": 30.0, "opsPerSecond": 100.0},
- {"name": "cache_upsert", "operations": 3, "elapsedMs": 60.0, "opsPerSecond": 50.0},
- ],
- },
- {
- "runtime": "python",
- "records": 3,
- "metrics": [
- {"name": "cache_get", "operations": 3, "elapsedMs": 10.0, "opsPerSecond": 300.0},
- {"name": "cache_upsert", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 150.0},
- ],
- },
- {
- "runtime": "python",
- "records": 3,
- "metrics": [
- {"name": "cache_get", "operations": 3, "elapsedMs": 20.0, "opsPerSecond": 200.0},
- {"name": "cache_upsert", "operations": 3, "elapsedMs": 40.0, "opsPerSecond": 75.0},
- ],
- },
- ]
+ def test_type_claimed_twice_rejected(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ cache = CultCache()
+ cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "a.cc"), ["item"])
+ with self.assertRaisesRegex(CultCacheError, "already routed"):
+ cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), ["settings", "item"])
+ # A refused registration claims nothing, so "settings" is still free.
+ cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "c.cc"), ["settings"])
- summarized = _median_result(samples)
+ def test_types_route_to_home_store(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ item = item_doc()
+ settings = define_database_entry_type("settings", [("theme", 0)])
+ generic_path = Path(tmp) / "generic.cc"
+ settings_path = Path(tmp) / "settings.cc"
- self.assertEqual(summarized["sampleCount"], 3)
- self.assertEqual(summarized["metrics"][0]["name"], "cache_get")
- self.assertEqual(summarized["metrics"][0]["elapsedMs"], 20.0)
- self.assertEqual(summarized["metrics"][0]["opsPerSecond"], 200.0)
- self.assertEqual(summarized["metrics"][1]["opsPerSecond"], 75.0)
+ def build() -> CultCache:
+ return (
+ CultCache.builder()
+ .register_document_type(item)
+ .register_document_type(settings)
+ .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
+ .add_generic_store(SingleFileMessagePackBackingStore(generic_path))
+ .build()
+ )
- def test_compare_csharp_marks_threshold_status_per_metric(self) -> None:
- python_result = {
- "metrics": [
- {"name": "cache_get", "operations": 3, "elapsedMs": 2.0, "opsPerSecond": 50.0},
- {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0},
- ],
- }
- csharp_result = {
- "metrics": [
- {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
- {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
- ],
- }
+ cache = build()
+ cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
+ cache.put(settings, "app", {"theme": "ash"})
- comparison = _compare_common_metrics(python_result, csharp_result, parity_threshold=0.90)
- by_name = {item["name"]: item for item in comparison}
+ generic = SingleFileMessagePackBackingStore(generic_path).pull_all()
+ routed = SingleFileMessagePackBackingStore(settings_path).pull_all()
+ self.assertEqual([(e.key, e.type) for e in generic], [("item:potion", "item")])
+ self.assertEqual([(e.key, e.type) for e in routed], [("app", "settings")])
- self.assertFalse(by_name["cache_get"]["meetsParityThreshold"])
- self.assertTrue(by_name["cache_upsert"]["meetsParityThreshold"])
- self.assertEqual(_parity_status(comparison, "ok"), "below-threshold")
- self.assertEqual(_parity_status([by_name["cache_upsert"]], "ok"), "meets-threshold")
- self.assertEqual(_parity_status(comparison, "failed"), "unknown")
+ cache.delete(settings, "app")
+ self.assertEqual(SingleFileMessagePackBackingStore(settings_path).pull_all(), [])
+ self.assertEqual(len(SingleFileMessagePackBackingStore(generic_path).pull_all()), 1)
- def test_compare_csharp_marks_meets_threshold_when_all_common_metrics_pass(self) -> None:
- comparison = _compare_common_metrics(
- {
- "metrics": [
- {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 95.0},
- {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 120.0},
- ],
- },
- {
- "metrics": [
- {"name": "cache_get", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
- {"name": "cache_upsert", "operations": 3, "elapsedMs": 1.0, "opsPerSecond": 100.0},
- ],
- },
- parity_threshold=0.90,
+ reloaded = build()
+ reloaded.pull_all_backing_stores()
+ self.assertEqual(reloaded.get_required(item, "item:potion").value, 50)
+ self.assertIsNone(reloaded.get(settings, "app"))
+
+ def test_attach_that_would_move_a_held_record_home_is_refused(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_database_entry_type("settings", [("theme", 0)])
+ generic_path = Path(tmp) / "generic.cc"
+ settings_path = Path(tmp) / "settings.cc"
+ cache = (
+ CultCache.builder()
+ .register_document_type(settings)
+ .add_generic_store(SingleFileMessagePackBackingStore(generic_path))
+ .build()
+ )
+ cache.put(settings, "app", {"theme": "v1-generic"})
+
+ with self.assertRaisesRegex(
+ CultCacheError, "would move settings from the generic store to the store routed to settings"
+ ):
+ cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
+ # Nothing attached: the next write still lands in the generic store.
+ cache.put(settings, "app", {"theme": "v2-generic"})
+ generic = SingleFileMessagePackBackingStore(generic_path).pull_all()
+ self.assertEqual([(e.key, e.type) for e in generic], [("app", "settings")])
+ self.assertFalse(settings_path.exists())
+
+ def test_load_from_a_store_that_is_not_the_record_home_is_refused(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_database_entry_type("settings", [("theme", 0)])
+ generic_path = Path(tmp) / "generic.cc"
+ settings_path = Path(tmp) / "settings.cc"
+ writer = CultCache.builder().register_document_type(settings).add_generic_store(
+ SingleFileMessagePackBackingStore(generic_path)
+ ).build()
+ writer.put(settings, "app", {"theme": "stray"})
+ generic_bytes = generic_path.read_bytes()
+
+ cache = (
+ CultCache.builder()
+ .register_document_type(settings)
+ .add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
+ .add_generic_store(SingleFileMessagePackBackingStore(generic_path))
+ .build()
+ )
+ # The cache already holds a record, in its home store, before the refused load.
+ cache.put(settings, "held", {"theme": "home"})
+ before = cache.snapshot_envelopes()
+ settings_bytes = settings_path.read_bytes()
+ with self.assertRaisesRegex(
+ CultCacheError,
+ "settings record app was loaded from the generic store, but its home is the store routed to settings",
+ ):
+ cache.pull_all_backing_stores()
+ self.assertIsNone(cache.get(settings, "app"))
+ self.assertEqual(cache.snapshot_envelopes(), before)
+ self.assertEqual(cache.get_required(settings, "held"), {"theme": "home"})
+ self.assertEqual(generic_path.read_bytes(), generic_bytes)
+ self.assertEqual(settings_path.read_bytes(), settings_bytes)
+
+ def test_pull_with_duplicate_global_leaves_cache_unchanged(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ item = item_doc()
+ settings = define_document_type("settings", global_document=True)
+ store_path = Path(tmp) / "generic.cc"
+ cache = (
+ CultCache.builder()
+ .register_document_type(item)
+ .register_document_type(settings)
+ .add_generic_store(SingleFileMessagePackBackingStore(store_path))
+ .build()
+ )
+ cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
+ cache.put_global(settings, {"theme": "ash"})
+ before = cache.snapshot_envelopes()
+
+ # A second record of the global type lands in the store file behind the cache's back.
+ stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray")
+ SingleFileMessagePackBackingStore(store_path).push(stray)
+
+ with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"):
+ cache.pull_all_backing_stores()
+ self.assertEqual(cache.snapshot_envelopes(), before)
+ self.assertEqual(cache.get_required(item, "item:potion").value, 50)
+ self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion")
+ self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
+
+ def test_pull_with_throwing_index_accessor_leaves_cache_unchanged(self) -> None:
+ def category(item: Item) -> str:
+ if item.name == "Bomb":
+ raise ValueError("category accessor refuses Bomb")
+ return item.category
+
+ with tempfile.TemporaryDirectory() as tmp:
+ item = define_document_type(
+ "item",
+ encode=lambda value: asdict(value),
+ decode=lambda raw: Item(**raw),
+ name="name",
+ indexes={"category": category},
+ )
+ settings = define_document_type("settings", global_document=True)
+ store_path = Path(tmp) / "generic.cc"
+ cache = (
+ CultCache.builder()
+ .register_document_type(item)
+ .register_document_type(settings)
+ .add_generic_store(SingleFileMessagePackBackingStore(store_path))
+ .build()
+ )
+ cache.put(item, "item:potion", Item(name="Potion", category="Consumable", value=50))
+ cache.put_global(settings, {"theme": "ash"})
+ before_envelopes = cache.snapshot_envelopes()
+ before_values = cache.snapshot()
+
+ # A record whose index accessor throws lands in the store file behind the cache's back.
+ bomb = replace(
+ cache.get_required_envelope(item, "item:potion"),
+ key="item:bomb",
+ payload=item.encode_payload(Item(name="Bomb", category="Explosive", value=1)),
+ )
+ SingleFileMessagePackBackingStore(store_path).push(bomb)
+
+ with self.assertRaisesRegex(ValueError, "category accessor refuses Bomb"):
+ cache.pull_all_backing_stores()
+ self.assertEqual(cache.snapshot_envelopes(), before_envelopes)
+ self.assertEqual(cache.snapshot(), before_values)
+ self.assertEqual(cache.get_required(item, "item:potion").value, 50)
+ self.assertIsNone(cache.get(item, "item:bomb"))
+ self.assertEqual(cache.get_key_by_name(item, "Potion"), "item:potion")
+ self.assertIsNone(cache.get_key_by_name(item, "Bomb"))
+ self.assertEqual(cache.get_key_by_index(item, "category", "Consumable"), "item:potion")
+ self.assertEqual(cache.get_required_global(settings)["theme"], "ash")
+
+ def _store_state(self, path: Path) -> list[tuple[str, str, bytes]]:
+ return sorted((e.type, e.key, e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all())
+
+ def test_put_with_throwing_name_extractor_changes_neither_store_nor_cache(self) -> None:
+ def name(value: dict) -> str:
+ if value["n"] == "bad":
+ raise ValueError("extractor refuses bad")
+ return value["n"]
+
+ with tempfile.TemporaryDirectory() as tmp:
+ item = define_document_type("item", name=name)
+ path = Path(tmp) / "generic.cc"
+ cache = CultCache.builder().register_document_type(item).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+ cache.put(item, "k", {"n": "good"})
+ store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
+
+ with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
+ cache.put(item, "k", {"n": "bad"})
+ self.assertEqual(self._store_state(path), store_before)
+ self.assertEqual(cache.snapshot_envelopes(), cache_before)
+ self.assertEqual(cache.get_required(item, "k"), {"n": "good"})
+ self.assertEqual(cache.get_key_by_name(item, "good"), "k")
+
+ def test_overwrite_under_a_registered_extractor_that_now_raises_changes_neither_store_nor_cache(self) -> None:
+ refusing = {"on": False}
+
+ def category(value: dict) -> str:
+ if refusing["on"]:
+ raise ValueError("extractor refuses now")
+ return value["c"]
+
+ with tempfile.TemporaryDirectory() as tmp:
+ item = define_document_type("item")
+ path = Path(tmp) / "generic.cc"
+ cache = CultCache.builder().register_document_type(item).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+ cache.put(item, "k", {"c": "old"})
+ cache.register_index(item, "category", category)
+ refusing["on"] = True
+ store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
+
+ with self.assertRaisesRegex(ValueError, "extractor refuses now"):
+ cache.put(item, "k", {"c": "new"})
+ self.assertEqual(self._store_state(path), store_before)
+ self.assertEqual(cache.snapshot_envelopes(), cache_before)
+ self.assertEqual(cache.get_key_by_index(item, "category", "old"), "k")
+ # Deleting runs no extractor: it uses the lookup keys stored when the record was admitted.
+ cache.delete(item, "k")
+ self.assertIsNone(cache.get_key_by_index(item, "category", "old"))
+
+ def test_registering_an_extractor_that_raises_on_a_held_value_installs_nothing(self) -> None:
+ def name(value: dict) -> str:
+ if value["n"] == "bad":
+ raise ValueError("extractor refuses bad")
+ return value["n"]
+
+ with tempfile.TemporaryDirectory() as tmp:
+ item = define_document_type("item")
+ path = Path(tmp) / "generic.cc"
+ cache = CultCache.builder().register_document_type(item).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+ cache.put(item, "old", {"n": "bad"})
+ with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
+ cache.register_name_lookup(item, name)
+ cache.put(item, "old", {"n": "fine"})
+ self.assertEqual(cache.get_required(item, "old"), {"n": "fine"})
+ self.assertIsNone(cache.get_key_by_name(item, "fine"))
+ self.assertEqual([item.decode_payload(e.payload) for e in SingleFileMessagePackBackingStore(path).pull_all()], [{"n": "fine"}])
+
+ def test_put_envelopes_with_throwing_extractor_changes_neither_store_nor_cache(self) -> None:
+ def name(value: dict) -> str:
+ if value["n"] == "bad":
+ raise ValueError("extractor refuses bad")
+ return value["n"]
+
+ with tempfile.TemporaryDirectory() as tmp:
+ item = define_document_type("item", name=name)
+ path = Path(tmp) / "generic.cc"
+ cache = CultCache.builder().register_document_type(item).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+ cache.put(item, "seed", {"n": "seed"})
+ seed = cache.get_required_envelope(item, "seed")
+ first = replace(seed, key="a", payload=item.encode_payload({"n": "a"}))
+ second = replace(seed, key="b", payload=item.encode_payload({"n": "bad"}))
+ store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
+
+ with self.assertRaisesRegex(ValueError, "extractor refuses bad"):
+ cache.put_envelopes(item, [first, second])
+ self.assertEqual(self._store_state(path), store_before)
+ self.assertEqual(cache.snapshot_envelopes(), cache_before)
+ self.assertIsNone(cache.get_key_by_name(item, "a"))
+
+ def test_global_replace_with_failing_store_push_keeps_old_global(self) -> None:
+ class FailingPushStore(SingleFileMessagePackBackingStore):
+ fail = False
+
+ def push(self, envelope):
+ if self.fail:
+ raise OSError("disk full")
+ super().push(envelope)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_document_type("settings", global_document=True)
+ path = Path(tmp) / "generic.cc"
+ store = FailingPushStore(path)
+ cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
+ cache.put_global(settings, {"theme": "base"})
+ store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
+ store.fail = True
+
+ with self.assertRaisesRegex(OSError, "disk full"):
+ cache.put_global(settings, {"theme": "new"})
+ with self.assertRaisesRegex(CultCacheError, "must use key __global__"):
+ cache.put(settings, "other", {"theme": "new"})
+ self.assertEqual(self._store_state(path), store_before)
+ self.assertEqual(cache.snapshot_envelopes(), cache_before)
+ self.assertEqual(cache.get_required_global(settings), {"theme": "base"})
+
+ @staticmethod
+ def _pausing_store(path: Path, barrier: threading.Barrier, resume: threading.Event):
+ """A store whose first push writes, meets the other thread at the barrier, then waits until that
+ thread's call has returned (or a timeout: a locked cache makes the other thread wait on us)."""
+
+ class PausingStore(SingleFileMessagePackBackingStore):
+ pushes = 0
+ paused = False
+ other_reached_store_while_paused = False
+
+ def push(self, envelope):
+ self.other_reached_store_while_paused |= self.paused
+ self.pushes += 1
+ super().push(envelope)
+ if self.pushes == 1:
+ self.paused = True
+ barrier.wait(timeout=5)
+ resume.wait(timeout=0.5)
+ self.paused = False
+
+ return PausingStore(path)
+
+ @staticmethod
+ def _run_racing(first, second, resume: threading.Event) -> list[BaseException]:
+ errors: list[BaseException] = []
+
+ def run(action, signal):
+ try:
+ action()
+ except BaseException as error: # noqa: BLE001 - reported to the test
+ errors.append(error)
+ finally:
+ if signal:
+ resume.set()
+
+ threads = [threading.Thread(target=run, args=(first, False)), threading.Thread(target=run, args=(second, True))]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join(timeout=10)
+ return errors
+
+ def test_concurrent_put_and_attach_cannot_land_record_in_non_home_store(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_database_entry_type("settings", [("theme", 0)])
+ generic_path, settings_path = Path(tmp) / "generic.cc", Path(tmp) / "settings.cc"
+ barrier, resume = threading.Barrier(2), threading.Event()
+ generic = self._pausing_store(generic_path, barrier, resume)
+ cache = CultCache.builder().register_document_type(settings).add_generic_store(generic).build()
+
+ def attach() -> None:
+ barrier.wait(timeout=5)
+ cache.add_backing_store(SingleFileMessagePackBackingStore(settings_path), ["settings"])
+
+ errors = self._run_racing(lambda: cache.put(settings, "app", {"theme": "t"}), attach, resume)
+
+ # Either the attach was refused (the record is held, so its home cannot move) or it won;
+ # in every case each held record must be in the store that is its home.
+ home = SingleFileMessagePackBackingStore(generic_path if errors else settings_path)
+ self.assertEqual([(e.key, e.type) for e in home.pull_all()], [("app", "settings")])
+ self.assertTrue(all(isinstance(error, CultCacheError) for error in errors), errors)
+
+ def test_concurrent_global_puts_cannot_both_reach_disk(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_document_type("settings", global_document=True)
+ path = Path(tmp) / "generic.cc"
+ barrier, resume = threading.Barrier(2), threading.Event()
+ store = self._pausing_store(path, barrier, resume)
+ cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
+
+ def second() -> None:
+ barrier.wait(timeout=5)
+ cache.put_global(settings, {"theme": "second"})
+
+ errors = self._run_racing(lambda: cache.put_global(settings, {"theme": "first"}), second, resume)
+
+ self.assertEqual(errors, [])
+ # While the first write was between its store push and its apply, the second never reached disk.
+ self.assertFalse(store.other_reached_store_while_paused)
+ on_disk = SingleFileMessagePackBackingStore(path).pull_all()
+ self.assertEqual(len(on_disk), 1)
+ self.assertEqual(on_disk[0].payload, cache.get_required_envelope(settings, CultCache.GLOBAL_KEY).payload)
+ self.assertEqual(cache.get_required_global(settings), {"theme": "second"})
+
+ @staticmethod
+ def _envelope(document, key: str, value) -> CultCacheEnvelope:
+ catalog_entry = document.catalog_entry()
+ return CultCacheEnvelope.create(
+ key=key,
+ type=document.type,
+ payload=document.encode_payload(value),
+ schema_id=catalog_entry.schema_id,
+ catalog_entry=catalog_entry,
)
- self.assertEqual(_parity_status(comparison, "ok"), "meets-threshold")
+ def test_legacy_key_global_loads_without_writing_and_first_write_leaves_one_global(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_document_type("settings", global_document=True)
+ path = Path(tmp) / "generic.cc"
+
+ def build() -> CultCache:
+ return CultCache.builder().register_document_type(settings).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+
+ SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"}))
+ stored_bytes = path.read_bytes()
+
+ cache = build()
+ cache.pull_all_backing_stores()
+ self.assertEqual(cache.get_global(settings), {"theme": "old"})
+ self.assertEqual(cache.get(settings, CultCache.GLOBAL_KEY), {"theme": "old"})
+ self.assertEqual(path.read_bytes(), stored_bytes)
+
+ cache.put_global(settings, {"theme": "new"})
+ self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], [CultCache.GLOBAL_KEY])
+ reloaded = build()
+ reloaded.pull_all_backing_stores()
+ self.assertEqual(reloaded.get_required_global(settings), {"theme": "new"})
+
+ def test_deleting_a_legacy_key_global_removes_the_legacy_record(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_document_type("settings", global_document=True)
+ path = Path(tmp) / "generic.cc"
+ SingleFileMessagePackBackingStore(path).push(self._envelope(settings, "legacy", {"theme": "old"}))
+ cache = CultCache.builder().register_document_type(settings).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+ cache.pull_all_backing_stores()
+
+ cache.delete_global(settings)
+ self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), [])
+ self.assertIsNone(cache.get_global(settings))
+
+ def test_two_globals_of_one_type_on_disk_are_refused_under_any_keys(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_document_type("settings", global_document=True)
+ for keys in (["one", "two"], ["legacy", CultCache.GLOBAL_KEY]):
+ path = Path(tmp) / f"{keys[0]}.cc"
+ store = SingleFileMessagePackBackingStore(path)
+ store.push_all([self._envelope(settings, key, {"theme": key}) for key in keys])
+ cache = CultCache.builder().register_document_type(settings).add_generic_store(store).build()
+ with self.assertRaisesRegex(CultCacheError, "Duplicate global document for type: settings"):
+ cache.pull_all_backing_stores()
+ self.assertIsNone(cache.get_global(settings))
+
+ def test_decoder_writing_during_pull_raises_and_changes_neither_store_nor_cache(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "generic.cc"
+ holder: dict[str, CultCache] = {}
+
+ def decode(raw):
+ holder["cache"].put(item, "side", {"name": "side"})
+ return raw
+
+ item = define_document_type("item", decode=decode, encode=lambda value: value)
+ store = SingleFileMessagePackBackingStore(path)
+ store.push(self._envelope(item, "a", {"name": "a"}))
+ stored_bytes = path.read_bytes()
+ cache = holder["cache"] = CultCache.builder().register_document_type(item).add_generic_store(store).build()
+
+ with self.assertRaisesRegex(CultCacheError, "re-entrant put"):
+ cache.pull_all_backing_stores()
+ self.assertEqual(path.read_bytes(), stored_bytes)
+ self.assertEqual(cache.snapshot_envelopes(), [])
+ self.assertIsNone(cache.get(item, "side"))
+ # The refusal released the cache: an ordinary write still works.
+ cache.put(item, "b", {"name": "b"})
+ self.assertEqual(cache.get(item, "b"), {"name": "b"})
+
+ def test_index_extractor_writing_during_register_index_raises_and_installs_nothing(self) -> None:
+ thing = define_document_type("thing")
+ cache = CultCache.builder().register_document_type(thing).build()
+ cache.put(thing, "k1", {"n": "one", "cat": "x"})
+
+ def category(value: dict) -> str:
+ cache.put(thing, "k2", {"n": "two", "cat": "y"})
+ return value["cat"]
+
+ with self.assertRaisesRegex(CultCacheError, "re-entrant put"):
+ cache.register_index(thing, "cat", category)
+ self.assertIsNone(cache.get(thing, "k2"))
+ self.assertNotIn("thing", cache._state.index_extractors)
+ self.assertIsNone(cache.get_key_by_index(thing, "cat", "x"))
+
+ def test_put_envelope_refuses_a_global_under_another_key(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_document_type("settings", global_document=True)
+ path = Path(tmp) / "generic.cc"
+ cache = CultCache.builder().register_document_type(settings).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+ cache.put_global(settings, {"theme": "base"})
+ store_before, cache_before = self._store_state(path), cache.snapshot_envelopes()
+ stray = replace(cache.get_required_envelope(settings, CultCache.GLOBAL_KEY), key="stray")
+
+ with self.assertRaisesRegex(CultCacheError, "must use key __global__"):
+ cache.put_envelope(settings, stray)
+ with self.assertRaisesRegex(CultCacheError, "must be non-empty"):
+ cache.put_envelope(settings, replace(stray, key=""))
+ self.assertEqual(self._store_state(path), store_before)
+ self.assertEqual(cache.snapshot_envelopes(), cache_before)
+
+ def test_refused_put_leaves_no_empty_type_entry(self) -> None:
+ def refuse(value: dict) -> dict:
+ raise ValueError("encode refuses")
+
+ with tempfile.TemporaryDirectory() as tmp:
+ other = define_document_type("other", encode=refuse)
+ path = Path(tmp) / "generic.cc"
+ cache = CultCache.builder().register_document_type(other).add_generic_store(
+ SingleFileMessagePackBackingStore(path)
+ ).build()
+ with self.assertRaisesRegex(ValueError, "encode refuses"):
+ cache.put(other, "x", {"a": 1})
+ self.assertEqual(cache.snapshot(), {})
+ self.assertFalse(path.exists())
+
+ def test_attach_with_empty_type_list_is_the_generic_store(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = define_database_entry_type("settings", [("theme", 0)])
+ store_path = Path(tmp) / "generic.cc"
+ cache = CultCache.builder().register_document_type(settings).build()
+ cache.add_backing_store(SingleFileMessagePackBackingStore(store_path), [])
+ cache.put(settings, "y", {"theme": "t"})
+ self.assertEqual(
+ [(e.key, e.type) for e in SingleFileMessagePackBackingStore(store_path).pull_all()],
+ [("y", "settings")],
+ )
+ with self.assertRaisesRegex(CultCacheError, "second generic store"):
+ cache.add_backing_store(SingleFileMessagePackBackingStore(Path(tmp) / "b.cc"), [])
+
+ def test_zero_store_cache_writes_and_deletes_in_memory(self) -> None:
+ settings = define_database_entry_type("settings", [("theme", 0)])
+ cache = CultCache.builder().register_document_type(settings).build()
+ cache.put(settings, "x", {"theme": "mem"})
+ self.assertEqual(cache.get_required(settings, "x"), {"theme": "mem"})
+ cache.delete(settings, "x")
+ self.assertIsNone(cache.get(settings, "x"))
def test_cache_put_without_backing_store_keeps_in_memory_value(self) -> None:
document = define_database_entry_type(
@@ -519,5893 +801,6 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(cache.get(document, "item:one"), {"name": "one", "value": 1})
self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one")
- def test_verify_reports_python_runtime_surface_health(self) -> None:
- result = verify(records=4)
-
- self.assertEqual(result["status"], "ok")
- self.assertEqual(
- {check["name"] for check in result["checks"]},
- {
- "public_exports",
- "typed_markers",
- "local_cultmesh_wire_smoke",
- "cultmesh_capability_truth",
- "benchmark_sanity",
- },
- )
- by_name = {check["name"]: check for check in result["checks"]}
- self.assertEqual(by_name["public_exports"]["missing"], {})
- self.assertEqual(by_name["cultmesh_capability_truth"]["failures"], [])
-
- def test_cultnet_security_options_match_cultlib_defaults_and_environment_rules(self) -> None:
- client = CultNetClientSecurityOptions.development()
- server = CultNetServerSecurityOptions.development()
-
- self.assertEqual(client.connection_key, "gamecult-dev-connection-key")
- self.assertEqual(server.connection_key, "gamecult-dev-connection-key")
- self.assertTrue(server.is_development)
- self.assertEqual(server.to_client_options().connection_key, client.connection_key)
- self.assertEqual(client.encryption_key(), hashlib.sha256(b"gamecult-dev-connection-key").digest())
- self.assertEqual(server.session_signing_key(), hashlib.sha256(b"gamecult-dev-session-signing-secret").digest())
- self.assertEqual(
- CultNetServerSecurityOptions.from_environment({}, allow_development_defaults=True),
- server,
- )
- with self.assertRaisesRegex(ValueError, "not configured"):
- CultNetServerSecurityOptions.from_environment({})
- with self.assertRaisesRegex(ValueError, "GAMECULT_SESSION_SIGNING_SECRET"):
- CultNetServerSecurityOptions.from_environment({"GAMECULT_CONNECTION_KEY": "key"})
- configured = CultNetServerSecurityOptions.from_environment({
- "GAMECULT_CONNECTION_KEY": "prod-key",
- "GAMECULT_SESSION_SIGNING_SECRET": "prod-secret",
- })
- self.assertEqual(configured.connection_key, "prod-key")
- self.assertFalse(configured.is_development)
-
- def test_cultnet_secret_helpers_validate_csharp_versioned_session_tokens(self) -> None:
- security = CultNetServerSecurityOptions("connection-key", "session-secret")
- user_id = uuid4()
- expires = datetime(2035, 1, 2, 3, 4, 5, tzinfo=UTC)
-
- token = CultNetSecret.create_session_token(user_id, expires, security, session_version=7)
- payload_b64, signature_b64 = token.split(".")
- self.assertEqual(
- CultNetSecret.from_base64url(payload_b64).decode("utf-8"),
- f"{user_id.hex}|{int(expires.timestamp())}|7",
- )
- self.assertEqual(
- CultNetSecret.from_base64url(signature_b64),
- hmac.new(
- hashlib.sha256(b"session-secret").digest(),
- CultNetSecret.from_base64url(payload_b64),
- hashlib.sha256,
- ).digest(),
- )
-
- validated = CultNetSecret.try_validate_session_token(
- token,
- security,
- now_utc=datetime(2035, 1, 2, 3, 4, 4, tzinfo=UTC),
- )
- self.assertIsNotNone(validated)
- assert validated is not None
- self.assertEqual(validated.user_id, user_id)
- self.assertEqual(validated.expires_at_utc, expires)
- self.assertEqual(validated.session_version, 7)
-
- tampered_payload = CultNetSecret.to_base64url(CultNetSecret.from_base64url(payload_b64) + b"tamper")
- tampered = f"{tampered_payload}.{signature_b64}"
- self.assertIsNone(CultNetSecret.try_validate_session_token(tampered, security))
- self.assertIsNone(CultNetSecret.try_validate_session_token(token, security, now_utc=expires))
-
- def test_cultnet_secret_helpers_validate_legacy_two_field_session_tokens(self) -> None:
- security = CultNetServerSecurityOptions.development()
- user_id = uuid4()
- expires_at_seconds = int(datetime(2036, 1, 1, tzinfo=UTC).timestamp())
- payload = f"{user_id.hex}|{expires_at_seconds}".encode("utf-8")
- signature = hmac.new(security.session_signing_key(), payload, hashlib.sha256).digest()
- token = f"{CultNetSecret.to_base64url(payload)}.{CultNetSecret.to_base64url(signature)}"
-
- validated = CultNetSecret.try_validate_session_token(
- token,
- security,
- now_utc=datetime(2035, 12, 31, tzinfo=UTC),
- )
-
- self.assertIsNotNone(validated)
- assert validated is not None
- self.assertEqual(validated.user_id, user_id)
- self.assertEqual(validated.session_version, 0)
-
- def test_cultnet_secret_encryption_helpers_are_optional_without_crypto_dependency(self) -> None:
- nonce = b"123456789012"
- security = CultNetServerSecurityOptions.development()
- try:
- encrypted = CultNetSecret.encrypt_string("hello", nonce, security.to_client_options())
- except ImportError as exc:
- self.assertIn("cryptography", str(exc))
- return
- self.assertEqual(CultNetSecret.decrypt_string(encrypted, nonce, security), "hello")
-
- def test_cultnet_schema_message_frame_round_trip(self) -> None:
- message = hello(
- runtime_id="python-test",
- supported_schema_versions=["cultnet.hello.v0"],
- transport_profiles=[
- {
- "schemaVersion": "cultnet.transport_profile.v0",
- "runtimeId": "python-test",
- "transports": [
- {
- "transportId": "test",
- "protocol": "tcp_framed",
- "channels": [{"channelId": "schema", "delivery": "reliable", "ordering": "ordered"}],
- }
- ],
- }
- ],
- )
- parsed = parse_message(decode_frame(encode_frame(message.to_bytes())))
- self.assertEqual(parsed.schema_version, "cultnet.hello.v0")
- self.assertEqual(parsed.body["runtimeId"], "python-test")
- self.assertEqual(parsed.body["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
-
- def test_cultnet_tcp_framed_transport_carries_schema_payloads_with_stats(self) -> None:
- payload = b"cultnet-payload"
- output = io.BytesIO()
- sender = TcpFramedTransportConnection(
- output,
- profile=create_tcp_framed_transport_profile("sender", transport_id="test-tcp"),
- )
- sender.send("schema", payload)
- self.assertEqual(sender.stats.frames_sent, 1)
- self.assertEqual(sender.stats.bytes_sent, len(payload) + 4)
- with self.assertRaisesRegex(ValueError, "only supports the schema channel"):
- sender.send("unreliable", b"")
-
- receiver = TcpFramedTransportConnection(
- io.BytesIO(output.getvalue()),
- profile=create_tcp_framed_transport_profile("receiver", transport_id="test-tcp"),
- )
- frame = receiver.receive()
- self.assertEqual(frame.channel_id, "schema")
- self.assertEqual(frame.payload, payload)
- self.assertEqual(receiver.stats.frames_received, 1)
- self.assertEqual(receiver.stats.bytes_received, len(payload) + 4)
- self.assertEqual(receiver.profile["transports"][0]["protocol"], "tcp_framed")
-
- def test_cultnet_raw_client_uses_schema_transport_factory(self) -> None:
- import msgpack # type: ignore
-
- sent_messages: list[dict[str, object]] = []
- test_case = self
-
- class FakeSchemaTransport:
- profile = create_tcp_framed_transport_profile("fake-client")
-
- def __enter__(self) -> "FakeSchemaTransport":
- return self
-
- def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
- self.close()
-
- def send(self, channel_id: str, payload: bytes) -> None:
- test_case.assertEqual(channel_id, "schema")
- sent_messages.append(msgpack.unpackb(payload, raw=False))
-
- def receive(self) -> object:
- response = {
- "schemaVersion": "cultnet.schema_catalog_response.v0",
- "messageId": "transport-factory",
- "schemas": [],
- }
- return type("Frame", (), {"channel_id": "schema", "payload": msgpack.packb(response, use_bin_type=True)})()
-
- def close(self) -> None:
- pass
-
- client = CultNetRawClient(
- "unused.example.test",
- 1,
- create_transport=FakeSchemaTransport,
- )
-
- response = client.fetch_schema_catalog(message_id="transport-factory")
-
- self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0")
- self.assertEqual(sent_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0")
- self.assertEqual(sent_messages[0]["messageId"], "transport-factory")
-
- def test_cultmesh_client_uses_rudp_schema_transport_endpoint(self) -> None:
- import msgpack # type: ignore
-
- server_socket = bind_udp_socket()
- server_host, server_port = server_socket.getsockname()[:2]
- connection_id = 0x43554C54
- server = CultNetRudpSocketTransportConnection(
- CultNetRudpSocketTransportOptions(
- runtime_id="python-rudp-schema-server",
- socket=server_socket,
- mode=CultNetRudpSocketMode.SERVER,
- connection_id=connection_id,
- initial_sequence=100,
- resend_delay_ms=25,
- )
- )
- received_messages: list[dict[str, object]] = []
-
- def serve_once() -> None:
- deadline = time.monotonic() + 2.0
- try:
- while time.monotonic() < deadline:
- frame = server.receive_once()
- server.poll_resends()
- if frame is None:
- time.sleep(0.005)
- continue
- received = msgpack.unpackb(frame.payload, raw=False)
- received_messages.append(received)
- response = {
- "schemaVersion": "cultnet.schema_catalog_response.v0",
- "messageId": received["messageId"],
- "schemas": [],
- }
- server.send("schema", msgpack.packb(response, use_bin_type=True))
- return
- raise AssertionError("RUDP schema server did not receive a request")
- finally:
- server.close()
-
- server_thread = threading.Thread(target=serve_once, daemon=True)
- server_thread.start()
- client = CultMesh.create_client(
- endpoint=f"rudp://{server_host}:{server_port}",
- timeout_seconds=2.0,
- connection_id=connection_id,
- runtime_id="python-rudp-schema-client",
- )
-
- response = client.fetch_schema_catalog(message_id="rudp-transport-factory")
- server_thread.join(timeout=2.0)
-
- self.assertFalse(server_thread.is_alive())
- self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0")
- self.assertEqual(response["messageId"], "rudp-transport-factory")
- self.assertEqual(received_messages[0]["schemaVersion"], "cultnet.schema_catalog_request.v0")
- self.assertEqual(received_messages[0]["messageId"], "rudp-transport-factory")
-
- def test_cultnet_rudp_packet_codec_uses_deterministic_reliable_ordered_fixture(self) -> None:
- encoded = encode_rudp_packet(
- CultNetRudpPacket(
- packet_type=CultNetRudpPacketType.DATA,
- connection_id=0x01020304,
- sequence=0x0000002A,
- ack=0x00000029,
- ack_mask=0x80000001,
- channel_id="schema",
- reliable=True,
- ordered=True,
- fragment_id=7,
- fragment_index=1,
- fragment_count=3,
- payload=b"hello",
- )
- )
-
- self.assertEqual(
- encoded.hex(),
- "434e523000030b2a010203040000002a0000002980000001000700010003000000050600736368656d6168656c6c6f",
- )
-
- decoded = decode_rudp_packet(encoded)
- self.assertEqual(decoded.packet_type, CultNetRudpPacketType.DATA)
- self.assertEqual(decoded.connection_id, 0x01020304)
- self.assertEqual(decoded.sequence, 0x0000002A)
- self.assertEqual(decoded.ack, 0x00000029)
- self.assertEqual(decoded.ack_mask, 0x80000001)
- self.assertEqual(decoded.channel_id, "schema")
- self.assertTrue(decoded.reliable)
- self.assertTrue(decoded.ordered)
- self.assertFalse(decoded.sequenced)
- self.assertEqual(decoded.fragment_id, 7)
- self.assertEqual(decoded.fragment_index, 1)
- self.assertEqual(decoded.fragment_count, 3)
- self.assertEqual(decoded.payload, b"hello")
-
- def test_cultnet_rudp_transport_profile_advertises_state_and_realtime_channels(self) -> None:
- profile = create_rudp_transport_profile(
- "python-rudp",
- transport_id="public-rudp",
- host="127.0.0.1",
- port=7777,
- max_payload_bytes=1200,
- max_fragment_bytes=1000,
- )
-
- self.assertEqual(profile["transports"][0]["protocol"], "rudp")
- self.assertEqual(profile["transports"][0]["reconnectPolicy"]["schemaVersion"], "cultnet.reconnect_policy.v0")
- self.assertEqual(profile["transports"][0]["reconnectPolicy"]["baseDelayMs"], 1_000)
- self.assertEqual(
- [
- (channel["channelId"], channel["delivery"], channel["ordering"])
- for channel in profile["transports"][0]["channels"]
- ],
- [
- ("schema", "reliable", "ordered"),
- ("latest", "unreliable", "sequenced"),
- ("realtime", "unreliable", "unordered"),
- ],
- )
-
- def test_cultnet_reconnect_policy_exposes_portable_delay_contract(self) -> None:
- policy = create_reconnect_policy(policy_id="rudp-default", max_attempts=8)
-
- self.assertEqual(policy.schema_version, "cultnet.reconnect_policy.v0")
- self.assertEqual(policy.policy_id, "rudp-default")
- self.assertEqual(policy.max_attempts, 8)
- self.assertEqual(policy.to_wire()["policyId"], "rudp-default")
- self.assertEqual(policy.to_wire()["maxAttempts"], 8)
- self.assertEqual(compute_reconnect_delay_ms(policy, 1), 1_000)
- self.assertEqual(compute_reconnect_delay_ms(policy, 3, 17), 4_017)
- self.assertEqual(compute_reconnect_delay_ms(policy, 9, 999), 30_250)
- self.assertEqual(compute_reconnect_delay_ms(policy, 0, -5), 1_000)
-
- def test_cultnet_reconnect_controller_schedules_attempts_and_reset(self) -> None:
- controller = CultNetReconnectController(create_reconnect_policy(max_attempts=2))
-
- first = controller.record_failure(10_000)
- self.assertEqual(first.attempt, 1)
- self.assertTrue(first.should_retry)
- self.assertEqual(first.delay_ms, 1_000)
- self.assertEqual(first.next_attempt_at_ms, 11_000)
- self.assertFalse(first.exhausted)
- self.assertFalse(controller.can_attempt(10_999))
- self.assertTrue(controller.can_attempt(11_000))
-
- second = controller.record_failure(11_000, 17)
- self.assertEqual(second.attempt, 2)
- self.assertEqual(second.delay_ms, 2_017)
- self.assertEqual(second.next_attempt_at_ms, 13_017)
- self.assertTrue(second.should_retry)
-
- exhausted = controller.record_failure(13_017)
- self.assertEqual(exhausted.attempt, 2)
- self.assertFalse(exhausted.should_retry)
- self.assertEqual(exhausted.delay_ms, 0)
- self.assertIsNone(exhausted.next_attempt_at_ms)
- self.assertTrue(exhausted.exhausted)
- self.assertFalse(controller.can_attempt(99_000))
-
- controller.reset()
- self.assertEqual(controller.attempt, 0)
- self.assertIsNone(controller.next_attempt_at_ms)
- self.assertFalse(controller.exhausted)
- self.assertTrue(controller.can_attempt(99_000))
-
- def test_cultnet_rudp_reconnect_loop_consumes_shared_controller(self) -> None:
- class FakeRudpReconnectTransport:
- def __init__(self) -> None:
- self.connect_calls = 0
- self.close_calls = 0
- self.last_payload: bytes | None = None
-
- def connect(self, payload: bytes = b"") -> None:
- self.connect_calls += 1
- self.last_payload = bytes(payload)
-
- def close(self) -> None:
- self.close_calls += 1
-
- now_ms = 10_000
- captured_callback: list[Callable[[], None]] = []
- transports: list[FakeRudpReconnectTransport] = []
-
- def create_transport() -> FakeRudpReconnectTransport:
- transport = FakeRudpReconnectTransport()
- transports.append(transport)
- return transport
-
- def scheduler(delay_ms: int, callback: Callable[[], None]) -> Callable[[], None]:
- self.assertEqual(delay_ms, 1_017)
- captured_callback[:] = [callback]
-
- def cancel() -> None:
- captured_callback.clear()
-
- return cancel
-
- loop = CultNetRudpReconnectLoop(
- create_transport,
- reconnect_policy=create_reconnect_policy(max_attempts=2),
- connect_payload=b"join",
- now_ms=lambda: now_ms,
- jitter_ms=lambda: 17,
- scheduler=scheduler,
- )
-
- first = loop.start()
- self.assertEqual(first.connect_calls, 1)
- self.assertEqual(first.last_payload, b"join")
- self.assertIs(loop.transport, first)
-
- decision = loop.handle_closed()
- self.assertIsNotNone(decision)
- self.assertEqual(decision.attempt, 1)
- self.assertTrue(decision.should_retry)
- self.assertEqual(decision.delay_ms, 1_017)
- self.assertEqual(decision.next_attempt_at_ms, 11_017)
- self.assertEqual(loop.reconnect_controller.attempt, 1)
- self.assertEqual(loop.reconnect_controller.next_attempt_at_ms, 11_017)
- self.assertEqual(len(captured_callback), 1)
-
- now_ms = 11_017
- captured_callback[0]()
- self.assertEqual(len(transports), 2)
- self.assertEqual(transports[1].connect_calls, 1)
- self.assertIs(loop.transport, transports[1])
-
- loop.mark_connected()
- self.assertEqual(loop.reconnect_controller.attempt, 0)
-
- loop.stop()
- self.assertEqual(transports[1].close_calls, 1)
- self.assertIsNone(loop.transport)
-
- def test_cultnet_rudp_session_handshake_acks_reliable_connect_and_accept_packets(self) -> None:
- client = CultNetRudpSession(
- CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=1, resend_delay_ms=50)
- )
- server = CultNetRudpSession(
- CultNetRudpSessionOptions(connection_id=0x0A0B0C0D, initial_sequence=100, resend_delay_ms=50)
- )
-
- connect = client.create_connect(0, b"join")
- self.assertEqual(connect.packet_type, CultNetRudpPacketType.CONNECT)
- self.assertEqual(connect.sequence, 1)
- self.assertEqual(client.pending_reliable_sequences, (1,))
-
- accept = server.accept_connect(connect, 10, b"ok")
- self.assertEqual(accept.packet_type, CultNetRudpPacketType.ACCEPT)
- self.assertEqual(accept.ack, 1)
- self.assertTrue(server.connected)
- self.assertEqual(server.pending_reliable_sequences, (100,))
-
- client.receive(accept, 20)
- self.assertTrue(client.connected)
- self.assertEqual(client.pending_reliable_sequences, ())
-
- ack = client.create_ack()
- self.assertEqual(ack.ack, 100)
- server.receive(ack, 30)
- self.assertEqual(server.pending_reliable_sequences, ())
-
- def test_cultnet_rudp_session_computes_ack_masks_and_clears_pending_reliable_packets(self) -> None:
- sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=7, initial_sequence=10, resend_delay_ms=100))
- receiver = CultNetRudpSession(
- CultNetRudpSessionOptions(connection_id=7, initial_sequence=200, resend_delay_ms=100)
- )
- sender.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 1, 0, 0, "control")
- )
- receiver.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 7, 2, 0, 0, "control")
- )
-
- first = sender.send(
- "schema",
- b"first",
- CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0),
- )
- second = sender.send(
- "schema",
- b"second",
- CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0),
- )
- third = sender.send(
- "schema",
- b"third",
- CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=0),
- )
- self.assertEqual(sender.pending_reliable_sequences, (10, 11, 12))
-
- receiver.receive(first)
- receiver.receive(third)
- ack_with_gap = receiver.create_ack()
- self.assertEqual(ack_with_gap.ack, 12)
- self.assertEqual(ack_with_gap.ack_mask, 0b10 | (1 << 9))
- sender.receive(ack_with_gap)
- self.assertEqual(sender.pending_reliable_sequences, (11,))
-
- receiver.receive(second)
- full_ack = receiver.create_ack()
- self.assertEqual(full_ack.ack, 12)
- self.assertEqual(full_ack.ack_mask, 0b11 | (1 << 9))
- sender.receive(full_ack)
- self.assertEqual(sender.pending_reliable_sequences, ())
-
- def test_cultnet_rudp_session_schedules_reliable_resends_until_acked(self) -> None:
- session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=99, initial_sequence=1, resend_delay_ms=100))
- session.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 99, 50, 0, 0, "control")
- )
- sent = session.send(
- "schema",
- b"payload",
- CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10),
- )
-
- self.assertEqual(session.due_resends(90), ())
- self.assertEqual(tuple(packet.sequence for packet in session.due_resends(110)), (sent.sequence,))
- self.assertEqual(session.due_resends(150), ())
-
- session.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACK, 99, 51, sent.sequence, 0, "control")
- )
- self.assertEqual(session.due_resends(250), ())
-
- def test_cultnet_rudp_session_pings_and_detects_receive_timeout(self) -> None:
- client = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=1))
- server = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=101, initial_sequence=100))
- connect = client.create_connect(0, b"join")
- accept = server.accept_connect(connect, 10)
- client.receive(accept, 20)
-
- ping = client.create_ping(b"pulse")
- ping_result = server.receive(ping, 30)
- self.assertIsNotNone(ping_result.reply)
- self.assertEqual(ping_result.reply.packet_type, CultNetRudpPacketType.PONG)
- self.assertEqual(ping_result.reply.payload, b"pulse")
-
- pong_result = client.receive(ping_result.reply, 40)
- self.assertTrue(pong_result.pong)
- self.assertEqual(pong_result.pong_payload, b"pulse")
- self.assertFalse(client.check_timeout(90, 50))
- self.assertTrue(client.check_timeout(91, 50))
- self.assertFalse(client.connected)
-
- def test_cultnet_rudp_session_bounds_pending_reliable_packets_before_enqueue(self) -> None:
- session = CultNetRudpSession(
- CultNetRudpSessionOptions(connection_id=102, initial_sequence=1, max_pending_reliable_packets=2)
- )
- session.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 102, 50, 0, 0, "control"))
- session.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True))
- session.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True))
- with self.assertRaisesRegex(ValueError, "reliable send queue is full"):
- session.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True))
- self.assertEqual(session.pending_reliable_sequences, (1, 2))
-
- fragmented = CultNetRudpSession(
- CultNetRudpSessionOptions(connection_id=103, initial_sequence=1, max_pending_reliable_packets=3)
- )
- fragmented.receive(CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 103, 50, 0, 0, "control"))
- with self.assertRaisesRegex(ValueError, "reliable send queue is full"):
- fragmented.send_many(
- "schema",
- b"fragment-me",
- CultNetRudpSendOptions(reliable=True, ordered=True),
- max_fragment_bytes=3,
- )
- self.assertEqual(fragmented.pending_reliable_sequences, ())
-
- def test_cultnet_rudp_session_suppresses_duplicates_and_delivers_reliable_ordered_payloads(self) -> None:
- sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=1))
- receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=123, initial_sequence=100))
- sender.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 90, 0, 0, "control")
- )
- receiver.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 123, 91, 0, 0, "control")
- )
-
- first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True))
- second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True))
- third = sender.send("schema", b"third", CultNetRudpSendOptions(reliable=True, ordered=True))
-
- self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"])
- self.assertEqual(receiver.receive(third).delivered, ())
- self.assertEqual(receiver.receive(first).delivered, ())
- self.assertEqual(
- [frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered],
- ["second", "third"],
- )
-
- def test_cultnet_rudp_session_skips_control_packets_while_ordering_schema_payloads(self) -> None:
- sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=1))
- receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=124, initial_sequence=100))
- sender.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 90, 0, 0, "control")
- )
- receiver.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 124, 91, 0, 0, "control")
- )
-
- first = sender.send("schema", b"first", CultNetRudpSendOptions(reliable=True, ordered=True))
- control = sender.create_ack()
- second = sender.send("schema", b"second", CultNetRudpSendOptions(reliable=True, ordered=True))
-
- self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(first).delivered], ["first"])
- self.assertEqual(receiver.receive(control).delivered, ())
- self.assertEqual([frame.payload.decode("utf-8") for frame in receiver.receive(second).delivered], ["second"])
-
- def test_cultnet_rudp_session_fragments_and_reassembles_reliable_ordered_payloads(self) -> None:
- sender = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=1))
- receiver = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=456, initial_sequence=100))
- sender.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 90, 0, 0, "control")
- )
- receiver.receive(
- CultNetRudpPacket(CultNetRudpPacketType.ACCEPT, 456, 91, 0, 0, "control")
- )
-
- packets = sender.send_many(
- "schema",
- b"fragment-me-please",
- CultNetRudpSendOptions(reliable=True, ordered=True, now_ms=10),
- max_fragment_bytes=5,
- )
- self.assertEqual(len(packets), 4)
- self.assertEqual(tuple(packet.fragment_count for packet in packets), (4, 4, 4, 4))
- self.assertEqual(tuple(packet.fragment_index for packet in packets), (0, 1, 2, 3))
- self.assertTrue(all(packet.fragment_id == packets[0].fragment_id for packet in packets))
-
- self.assertEqual(receiver.receive(packets[0]).delivered, ())
- self.assertEqual(receiver.receive(packets[1]).delivered, ())
- self.assertEqual(receiver.receive(packets[2]).delivered, ())
- delivered = receiver.receive(packets[3]).delivered
- self.assertEqual(len(delivered), 1)
- self.assertEqual(delivered[0].payload, b"fragment-me-please")
- self.assertEqual(delivered[0].sequence, packets[0].sequence)
-
- def test_cultnet_rudp_socket_transport_handshakes_and_carries_reliable_ordered_schema_frames(self) -> None:
- server_socket = bind_udp_socket()
- client_socket = bind_udp_socket()
- connection_id = 0x10203040
- server = CultNetRudpSocketTransportConnection(
- CultNetRudpSocketTransportOptions(
- runtime_id="python-rudp-server",
- socket=server_socket,
- mode=CultNetRudpSocketMode.SERVER,
- connection_id=connection_id,
- initial_sequence=100,
- resend_delay_ms=25,
- )
- )
- client = CultNetRudpSocketTransportConnection(
- CultNetRudpSocketTransportOptions(
- runtime_id="python-rudp-client",
- socket=client_socket,
- mode=CultNetRudpSocketMode.CLIENT,
- remote_addr=server_socket.getsockname(),
- connection_id=connection_id,
- initial_sequence=1,
- resend_delay_ms=25,
- )
- )
-
- try:
- client.connect(b"join")
- pump_rudp_handshake(client, server)
- self.assertTrue(client.connected)
- self.assertTrue(server.connected)
-
- client.send("schema", b"client-state")
- server_frame = receive_rudp_frame(server)
- self.assertEqual(server_frame.channel_id, "schema")
- self.assertEqual(server_frame.payload, b"client-state")
-
- server.send("schema", b"server-state")
- client_frame = receive_rudp_frame(client)
- self.assertEqual(client_frame.channel_id, "schema")
- self.assertEqual(client_frame.payload, b"server-state")
- self.assertEqual(server.profile["transports"][0]["protocol"], "rudp")
- self.assertEqual(client.stats.frames_sent, 1)
- self.assertEqual(server.stats.frames_received, 1)
- finally:
- client.close()
- server.close()
-
- def test_cultnet_rudp_socket_transport_carries_fragmented_reliable_ordered_schema_frames(self) -> None:
- server_socket = bind_udp_socket()
- client_socket = bind_udp_socket()
- connection_id = 0x10203041
- server = CultNetRudpSocketTransportConnection(
- CultNetRudpSocketTransportOptions(
- runtime_id="python-rudp-fragment-server",
- socket=server_socket,
- mode=CultNetRudpSocketMode.SERVER,
- connection_id=connection_id,
- initial_sequence=100,
- resend_delay_ms=25,
- max_fragment_bytes=8,
- )
- )
- client = CultNetRudpSocketTransportConnection(
- CultNetRudpSocketTransportOptions(
- runtime_id="python-rudp-fragment-client",
- socket=client_socket,
- mode=CultNetRudpSocketMode.CLIENT,
- remote_addr=server_socket.getsockname(),
- connection_id=connection_id,
- initial_sequence=1,
- resend_delay_ms=25,
- max_fragment_bytes=8,
- )
- )
-
- try:
- payload = b"this-schema-frame-is-larger-than-one-rudp-fragment"
- client.connect(b"join")
- pump_rudp_handshake(client, server)
- client.send("schema", payload)
- server_frame = receive_rudp_frame(server)
- self.assertEqual(server_frame.channel_id, "schema")
- self.assertEqual(server_frame.payload, payload)
- self.assertEqual(client.stats.frames_sent, 1)
- self.assertEqual(server.stats.frames_received, 1)
- finally:
- client.close()
- server.close()
-
- def test_cultmesh_facade_creates_rudp_client_from_peer_endpoint(self) -> None:
- connection_id = 0x10203042
- server = CultMesh.create_rudp_server(
- "python-cultmesh-rudp-server",
- connection_id,
- initial_sequence=100,
- resend_delay_ms=25,
- max_fragment_bytes=1024,
- max_pending_reliable_packets=16,
- )
- server_host, server_port = server.socket.getsockname()[:2]
- endpoint = CultMesh.parse_rudp_endpoint(f"rudp://{server_host}:{server_port}")
- self.assertEqual(endpoint.host, server_host)
- self.assertEqual(endpoint.port, server_port)
- peer = CultMeshPeerCard(
- peer_id="python-cultmesh-rudp-server",
- verse_id="local",
- endpoints=(endpoint.uri,),
- roles=("schema",),
- authority_lease_id="lease:python-cultmesh-rudp-server",
- )
- peers = CultMesh.create_peer_catalog()
- leases = CultMesh.create_authority_lease_catalog()
- peers.upsert(peer)
- with self.assertRaisesRegex(ValueError, "No authorized RUDP peer"):
- CultMesh.create_rudp_client_for_authorized_peer(
- "python-cultmesh-rudp-client",
- connection_id,
- peers,
- leases,
- "local",
- "schema",
- resend_delay_ms=25,
- max_fragment_bytes=1024,
- max_pending_reliable_packets=16,
- )
- leases.upsert(
- CultMeshAuthorityLease(
- lease_id="lease:python-cultmesh-rudp-server",
- verse_id="local",
- peer_id="python-cultmesh-rudp-server",
- roles=("schema",),
- valid_from=datetime.now(UTC) - timedelta(seconds=1),
- expires_at=datetime.now(UTC) + timedelta(seconds=30),
- issuer_runtime_id="python-authority",
- )
- )
- client = CultMesh.create_rudp_client_for_authorized_peer(
- "python-cultmesh-rudp-client",
- connection_id,
- peers,
- leases,
- "local",
- "schema",
- resend_delay_ms=25,
- max_fragment_bytes=1024,
- max_pending_reliable_packets=16,
- )
-
- try:
- client.connect(b"join")
- pump_rudp_handshake(client, server)
- self.assertTrue(client.connected)
- self.assertTrue(server.connected)
-
- client.send("schema", b"client-state")
- server_frame = receive_rudp_frame(server)
- self.assertEqual(server_frame.channel_id, "schema")
- self.assertEqual(server_frame.payload, b"client-state")
- self.assertEqual(server.profile["transports"][0]["protocol"], "rudp")
- self.assertEqual(client.profile["transports"][0]["protocol"], "rudp")
- finally:
- client.close()
- server.close()
-
- def test_cultnet_database_subscription_helpers_match_schema_v0_shape(self) -> None:
- subscribe = database_subscribe(
- message_id="sub-message",
- subscription_id="sub-1",
- schema_ids=["schema-a"],
- record_keys=["record-a"],
- include_snapshot=False,
- ).to_wire()
- self.assertEqual(subscribe["schemaVersion"], "cultnet.database_subscribe.v0")
- self.assertEqual(subscribe["messageId"], "sub-message")
- self.assertEqual(subscribe["subscriptionId"], "sub-1")
- self.assertEqual(subscribe["schemaIds"], ["schema-a"])
- self.assertEqual(subscribe["recordKeys"], ["record-a"])
- self.assertFalse(subscribe["includeSnapshot"])
-
- unsubscribe = database_unsubscribe(message_id="unsub-message", subscription_id="sub-1").to_wire()
- self.assertEqual(unsubscribe["schemaVersion"], "cultnet.database_unsubscribe.v0")
- self.assertEqual(unsubscribe["messageId"], "unsub-message")
- self.assertEqual(unsubscribe["subscriptionId"], "sub-1")
-
- def test_cultnet_database_change_parses_put_and_delete_shapes(self) -> None:
- put_change = CultNetDatabaseChange.from_wire({
- "schemaVersion": "cultnet.database_change_raw.v0",
- "messageId": "change-put",
- "subscriptionId": "sub-1",
- "changeKind": "added",
- "document": {
- "schemaId": "schema-note",
- "recordKey": "note:1",
- "payload": b"payload",
- },
- })
- self.assertEqual(put_change.schema_id, "schema-note")
- self.assertEqual(put_change.record_key, "note:1")
- self.assertIsInstance(put_change.raw_document, CultNetRawDocumentRecord)
- self.assertEqual(put_change.raw_document.payload, b"payload")
- self.assertEqual(put_change.to_wire()["document"]["recordKey"], "note:1")
-
- delete_change = CultNetDatabaseChange.from_wire({
- "schemaVersion": "cultnet.database_change_raw.v0",
- "messageId": "change-delete",
- "subscriptionId": "sub-1",
- "changeKind": "removed",
- "schemaId": "schema-note",
- "recordKey": "note:1",
- })
- self.assertIsNone(delete_change.document)
- self.assertIsNone(delete_change.raw_document)
- self.assertEqual(delete_change.schema_id, "schema-note")
- self.assertEqual(delete_change.to_wire()["recordKey"], "note:1")
-
- def test_cultnet_raw_put_helper_carries_message_id(self) -> None:
- put = document_put_raw(
- message_id="put-1",
- key="record-a",
- schema_id="schema-a",
- stored_at="2026-06-13T00:00:00Z",
- payload=b"payload",
- source_runtime_id="python-test",
- shard_id="interop",
- shard_epoch=1,
- ).to_wire()
- self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0")
- self.assertEqual(put["messageId"], "put-1")
- self.assertEqual(put["document"]["recordKey"], "record-a")
- self.assertEqual(put["document"]["sourceRuntimeId"], "python-test")
- self.assertEqual(put["shardId"], "interop")
- self.assertEqual(put["shardEpoch"], 1)
-
- def test_cultnet_catalog_and_snapshot_helpers_accept_filters(self) -> None:
- catalog = schema_catalog_request(
- message_id="catalog-1",
- include_schema_json=True,
- schema_ids=["schema-a"],
- kinds=["wire_message"],
- ).to_wire()
- self.assertEqual(catalog["schemaVersion"], "cultnet.schema_catalog_request.v0")
- self.assertEqual(catalog["messageId"], "catalog-1")
- self.assertTrue(catalog["includeSchemaJson"])
- self.assertEqual(catalog["schemaIds"], ["schema-a"])
- self.assertEqual(catalog["kinds"], ["wire_message"])
-
- snapshot = snapshot_request(
- message_id="snapshot-1",
- schema_ids=["schema-a"],
- record_keys=["record-a"],
- shard_id="interop",
- shard_epoch=1,
- ).to_wire()
- self.assertEqual(snapshot["schemaVersion"], "cultnet.snapshot_request.v0")
- self.assertEqual(snapshot["messageId"], "snapshot-1")
- self.assertEqual(snapshot["schemaIds"], ["schema-a"])
- self.assertEqual(snapshot["recordKeys"], ["record-a"])
- self.assertEqual(snapshot["shardId"], "interop")
- self.assertEqual(snapshot["shardEpoch"], 1)
-
- def test_cultnet_raw_snapshot_response_filters_and_roundtrips_records(self) -> None:
- response = CultNetRawSnapshotResponse.from_wire({
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": "snapshot-1",
- "shardId": "notes",
- "shardEpoch": 3,
- "shardLogSequence": 8,
- "documents": [
- CultNetRawDocumentRecord(
- schema_id="schema-note",
- record_key="note:1",
- stored_at="2026-06-14T00:00:00Z",
- payload=b"note",
- source_runtime_id="python-test",
- tags=("snapshot",),
- ).to_wire(),
- {
- "schemaId": "schema-fact",
- "recordKey": "fact:1",
- "payloadEncoding": "messagepack",
- "payload": b"fact",
- },
- ],
- })
- self.assertEqual(response.shard_id, "notes")
- self.assertEqual(response.shard_epoch, 3)
- self.assertEqual(response.shard_log_sequence, 8)
- self.assertEqual(response.documents[0].source_runtime_id, "python-test")
- self.assertEqual(response.filter(schema_ids=["schema-note"])[0].record_key, "note:1")
- self.assertEqual(response.filter(record_keys=["fact:1"])[0].schema_id, "schema-fact")
- self.assertEqual(response.to_wire()["documents"][0]["tags"], ["snapshot"])
-
- def test_cultnet_raw_client_fetches_schema_snapshot_and_shard_reads(self) -> None:
- import msgpack # type: ignore
- from cultnet_py import read_frame, write_frame
-
- received_versions: list[str] = []
- ready = threading.Event()
- server_error: list[BaseException] = []
- port_holder: list[int] = []
-
- def serve_requests() -> None:
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind(("127.0.0.1", 0))
- port_holder.append(server.getsockname()[1])
- server.listen(4)
- ready.set()
- for _ in range(4):
- connection, _ = server.accept()
- with connection:
- stream = connection.makefile("rwb")
- request = msgpack.unpackb(read_frame(stream), raw=False)
- received_versions.append(request["schemaVersion"])
- if request["schemaVersion"] == "cultnet.schema_catalog_request.v0":
- self.assertEqual(request["kinds"], ["wire_message"])
- response = {"schemaVersion": "cultnet.schema_catalog_response.v0", "messageId": request["messageId"], "schemas": []}
- elif request["schemaVersion"] == "cultnet.snapshot_request.v0":
- self.assertEqual(request["shardId"], "interop")
- response = {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": request["messageId"], "documents": []}
- elif request["schemaVersion"] == "cultnet.shard_catalog_request.v0":
- response = {
- "schemaVersion": "cultnet.shard_catalog_response.v0",
- "messageId": request["messageId"],
- "shards": [{
- "shardId": "interop",
- "ownerRuntimeId": "socket-test",
- "epoch": 1,
- "schemaIds": request["schemaIds"],
- "keyPrefix": "record-",
- "primaryEndpoints": ["cultnet://127.0.0.1:1"],
- }],
- }
- elif request["schemaVersion"] == "cultnet.shard_log_request.v0":
- self.assertEqual(request["afterSequence"], 7)
- response = {
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": request["messageId"],
- "shardId": request["shardId"],
- "shardEpoch": request["shardEpoch"],
- "entries": [],
- "resyncRequired": False,
- }
- else:
- raise AssertionError(f"unexpected request {request['schemaVersion']}")
- write_frame(stream, msgpack.packb(response, use_bin_type=True))
- stream.flush()
- except BaseException as error:
- server_error.append(error)
- ready.set()
-
- thread = threading.Thread(target=serve_requests, daemon=True)
- thread.start()
- self.assertTrue(ready.wait(2.0))
- self.assertFalse(server_error)
-
- client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
- self.assertEqual(client.fetch_schema_catalog(kinds=["wire_message"])["schemaVersion"], "cultnet.schema_catalog_response.v0")
- typed_snapshot = client.fetch_snapshot_response(shard_id="interop", shard_epoch=1)
- self.assertEqual(typed_snapshot.shard_id, None)
- self.assertEqual(typed_snapshot.documents, ())
- shard_catalog = CultNetShardCatalog()
- synced_shards = client.sync_shard_catalog(shard_catalog, schema_ids=["schema-a"])
- self.assertEqual(synced_shards[0].shard_id, "interop")
- self.assertEqual(shard_catalog.get("interop"), synced_shards[0])
- shard_log = client.fetch_shard_log_response(shard_id="interop", shard_epoch=1, after_sequence=7)
- self.assertEqual(shard_log.shard_id, "interop")
- self.assertFalse(shard_log.resync_required)
- self.assertEqual(shard_log.last_sequence, 0)
-
- thread.join(2.0)
- self.assertFalse(server_error)
- self.assertEqual(received_versions, [
- "cultnet.schema_catalog_request.v0",
- "cultnet.snapshot_request.v0",
- "cultnet.shard_catalog_request.v0",
- "cultnet.shard_log_request.v0",
- ])
-
- def test_cultnet_raw_client_raises_typed_peer_error_response(self) -> None:
- import msgpack # type: ignore
- from cultnet_py import read_frame, write_frame
-
- ready = threading.Event()
- port_holder: list[int] = []
-
- def serve_error() -> None:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind(("127.0.0.1", 0))
- port_holder.append(server.getsockname()[1])
- server.listen(1)
- ready.set()
- connection, _ = server.accept()
- with connection:
- stream = connection.makefile("rwb")
- read_frame(stream)
- write_frame(stream, msgpack.packb({
- "schemaVersion": "cultnet.error.v0",
- "messageId": "error-1",
- "error": "Snapshot document limit exceeded",
- }, use_bin_type=True))
- stream.flush()
-
- thread = threading.Thread(target=serve_error, daemon=True)
- thread.start()
- self.assertTrue(ready.wait(2.0))
-
- client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
- with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised:
- client.fetch_snapshot()
-
- self.assertEqual(raised.exception.peer_error, "Snapshot document limit exceeded")
- self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0")
- thread.join(2.0)
-
- def test_cultnet_database_subscription_reads_snapshot_and_change(self) -> None:
- import msgpack # type: ignore
- from cultnet_py import read_frame, write_frame
-
- document = define_database_entry_type(
- "sub.item",
- [
- ("name", 0),
- ("category", 1),
- ("value", 2, 0),
- ],
- cls=Item,
- )
- schema_id = document.catalog_entry().schema_id
- ready = threading.Event()
- server_error: list[BaseException] = []
- port_holder: list[int] = []
- received_versions: list[str] = []
-
- def serve_subscription() -> None:
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind(("127.0.0.1", 0))
- port_holder.append(server.getsockname()[1])
- server.listen(1)
- ready.set()
- connection, _ = server.accept()
- with connection:
- stream = connection.makefile("rwb")
- subscribe = msgpack.unpackb(read_frame(stream), raw=False)
- received_versions.append(subscribe["schemaVersion"])
- self.assertEqual(subscribe["subscriptionId"], "sub-1")
- snapshot = {
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": subscribe["messageId"],
- "documents": [],
- }
- write_frame(stream, msgpack.packb(snapshot, use_bin_type=True))
- stream.flush()
-
- put = msgpack.unpackb(read_frame(stream), raw=False)
- received_versions.append(put["schemaVersion"])
- change = {
- "schemaVersion": "cultnet.database_change_raw.v0",
- "messageId": "change-1",
- "subscriptionId": "sub-1",
- "changeKind": "added",
- "document": put["document"],
- }
- write_frame(stream, msgpack.packb(change, use_bin_type=True))
- stream.flush()
-
- unsubscribe = msgpack.unpackb(read_frame(stream), raw=False)
- received_versions.append(unsubscribe["schemaVersion"])
- except BaseException as error:
- server_error.append(error)
- ready.set()
-
- thread = threading.Thread(target=serve_subscription, daemon=True)
- thread.start()
- self.assertTrue(ready.wait(2.0))
- self.assertFalse(server_error)
-
- client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
- put = document_put_raw(
- message_id="put-sub",
- key="item:sub",
- schema_id=schema_id,
- stored_at="2026-06-14T00:00:00Z",
- payload=document.encode_payload(Item("orb", "gear", 8)),
- )
- with client.subscribe_database(subscription_id="sub-1", schema_ids=[schema_id]) as subscription:
- snapshot = subscription.read_next_snapshot_response()
- subscription.send(put)
- change = subscription.read_next_change()
-
- thread.join(2.0)
- self.assertFalse(server_error)
- self.assertIsInstance(snapshot, CultNetRawSnapshotResponse)
- self.assertEqual(snapshot.message_id, "cultnet-python-subscribe")
- self.assertEqual(snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0")
- self.assertEqual(change.change_kind, "added")
- self.assertEqual(change.record_key, "item:sub")
- self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord)
- self.assertEqual(change.raw_document.record_key, "item:sub")
- self.assertEqual(received_versions, [
- "cultnet.database_subscribe.v0",
- "cultnet.document_put_raw.v0",
- "cultnet.database_unsubscribe.v0",
- ])
-
- def test_cultnet_database_subscription_raises_typed_peer_error(self) -> None:
- import msgpack # type: ignore
- from cultnet_py import read_frame, write_frame
-
- ready = threading.Event()
- port_holder: list[int] = []
-
- def serve_subscription_error() -> None:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind(("127.0.0.1", 0))
- port_holder.append(server.getsockname()[1])
- server.listen(1)
- ready.set()
- connection, _ = server.accept()
- with connection:
- stream = connection.makefile("rwb")
- read_frame(stream)
- write_frame(stream, msgpack.packb({
- "schemaVersion": "cultnet.error.v0",
- "messageId": "subscription-error",
- "error": "subscription rejected",
- }, use_bin_type=True))
- stream.flush()
-
- thread = threading.Thread(target=serve_subscription_error, daemon=True)
- thread.start()
- self.assertTrue(ready.wait(2.0))
-
- client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
- with client.subscribe_database(subscription_id="sub-error") as subscription:
- with self.assertRaisesRegex(CultNetPeerError, "subscription rejected") as raised:
- subscription.read_next()
- self.assertEqual(raised.exception.response["messageId"], "subscription-error")
- thread.join(2.0)
-
- def test_cultnet_replication_helpers_apply_raw_snapshot_and_shard_log(self) -> None:
- document = define_database_entry_type(
- "replica.item",
- [
- ("name", 0),
- ("category", 1),
- ("value", 2, 0),
- ],
- cls=Item,
- )
- cache = CultCache()
- cache.register_document_type(document)
- schema_id = document.catalog_entry().schema_id
-
- snapshot = {
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": "snapshot-1",
- "documents": [
- {
- "schemaId": schema_id,
- "recordKey": "item:1",
- "storedAt": "2026-06-13T00:00:00Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload(Item("sword", "gear", 3)),
- }
- ],
- }
- applied_snapshot = apply_raw_snapshot(cache, [document], snapshot)
- self.assertEqual(applied_snapshot[0].record_key, "item:1")
- self.assertEqual(cache.get_required(document, "item:1").value, 3)
- typed_snapshot = CultNetRawSnapshotResponse.from_wire(snapshot)
- direct_applied = apply_raw_document_record(cache, schema_document_map([document]), typed_snapshot.documents[0])
- self.assertEqual(direct_applied.schema_id, schema_id)
- self.assertEqual(direct_applied.record_key, "item:1")
- typed_snapshot_applied = apply_raw_snapshot(cache, [document], typed_snapshot)
- self.assertEqual(typed_snapshot_applied[0].record_key, "item:1")
-
- shard_log = {
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "log-1",
- "shardId": "interop",
- "shardEpoch": 1,
- "resyncRequired": False,
- "entries": [
- {
- "sequence": 0,
- "changeKind": "updated",
- "put": {
- "schemaVersion": "cultnet.document_put_raw.v0",
- "messageId": "put-unknown",
- "document": {
- "schemaId": "replica.unknown.v1",
- "recordKey": "unknown:1",
- "storedAt": "2026-06-13T00:00:01Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload(Item("ignored", "unknown", 0)),
- },
- "shardId": "interop",
- "shardEpoch": 1,
- },
- },
- {
- "sequence": 1,
- "changeKind": "updated",
- "put": {
- "schemaVersion": "cultnet.document_put_raw.v0",
- "messageId": "put-1",
- "document": {
- "schemaId": schema_id,
- "recordKey": "item:1",
- "storedAt": "2026-06-13T00:00:01Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload(Item("sword", "gear", 12)),
- },
- "shardId": "interop",
- "shardEpoch": 1,
- },
- },
- {
- "sequence": 2,
- "changeKind": "removed",
- "delete": {
- "schemaVersion": "cultnet.document_delete.v0",
- "messageId": "delete-1",
- "schemaId": schema_id,
- "recordKey": "item:1",
- "shardId": "interop",
- "shardEpoch": 1,
- },
- },
- ],
- }
- applied_log = apply_shard_log_response(cache, [document], shard_log)
- self.assertEqual([change.change_kind for change in applied_log], ["updated", "removed"])
- self.assertIsNone(cache.get(document, "item:1"))
-
- cache.put(document, "item:1", Item("sword", "gear", 3))
- typed_shard_log = CultNetShardLogResponse.from_wire({
- **shard_log,
- "entries": shard_log["entries"][1:],
- })
- typed_applied_log = apply_shard_log_response(cache, [document], typed_shard_log)
- self.assertEqual([change.change_kind for change in typed_applied_log], ["updated", "removed"])
- self.assertIsNone(cache.get(document, "item:1"))
-
- def test_cultnet_replication_helpers_recover_schema_stamped_raw_records(self) -> None:
- document = define_database_entry_type(
- "replica.runtime-policy",
- [
- ("schema_version", 0),
- ("name", 1),
- ("value", 2),
- ],
- schema_id="replica.runtime-policy.current",
- schema_name="replica.runtime_policy",
- schema_version="replica.runtime_policy.v1",
- )
- cache = CultCache()
- cache.register_document_type(document)
- stale_schema_id = "sha256:stale-replica-runtime-policy"
- payload = document.encode_payload({
- "schema_version": "replica.runtime_policy.v1",
- "name": "policy",
- "value": "still synced",
- })
- record = {
- "schemaId": stale_schema_id,
- "recordKey": "policy:1",
- "storedAt": "2026-06-25T00:00:00Z",
- "payloadEncoding": "messagepack",
- "payload": payload,
- }
- snapshot = {
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": "snapshot-stale",
- "documents": [record],
- }
-
- applied_snapshot = apply_raw_snapshot(cache, [document], snapshot)
- local_schema_id = document.catalog_entry().schema_id
- self.assertEqual(applied_snapshot[0].schema_id, local_schema_id)
- self.assertEqual(cache.get_required(document, "policy:1")["value"], "still synced")
- self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
- direct_applied = apply_raw_document_record(cache, schema_document_map([document]), record)
- self.assertEqual(direct_applied.schema_id, local_schema_id)
- self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
-
- shard_log = {
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "log-stale",
- "shardId": "interop",
- "shardEpoch": 1,
- "resyncRequired": False,
- "entries": [
- {
- "sequence": 0,
- "changeKind": "updated",
- "put": {
- "schemaVersion": "cultnet.document_put_raw.v0",
- "messageId": "put-stale",
- "document": {
- **record,
- "payload": document.encode_payload({
- "schema_version": "replica.runtime_policy.v1",
- "name": "policy",
- "value": "updated through log",
- }),
- },
- "shardId": "interop",
- "shardEpoch": 1,
- },
- }
- ],
- }
-
- applied_log = apply_shard_log_response(cache, [document], shard_log)
- self.assertEqual(applied_log[0].schema_id, local_schema_id)
- self.assertEqual(cache.get_required(document, "policy:1")["value"], "updated through log")
- self.assertEqual(cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
-
- delete_log = {
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "log-delete-alias",
- "shardId": "interop",
- "shardEpoch": 1,
- "resyncRequired": False,
- "entries": [
- {
- "sequence": 1,
- "changeKind": "removed",
- "delete": {
- "schemaVersion": "cultnet.document_delete.v0",
- "messageId": "delete-version-alias",
- "schemaId": "replica.runtime_policy.v1",
- "recordKey": "policy:1",
- "shardId": "interop",
- "shardEpoch": 1,
- },
- }
- ],
- }
- applied_delete = apply_shard_log_response(cache, [document], delete_log)
- self.assertEqual(applied_delete[0].schema_id, local_schema_id)
- self.assertEqual(applied_delete[0].change_kind, "removed")
- self.assertIsNone(cache.get(document, "policy:1"))
-
- def test_cultmesh_node_applies_foreign_schema_snapshot_as_local_update(self) -> None:
- document = define_database_entry_type(
- "mesh.runtime-policy",
- [
- ("schema_version", 0),
- ("name", 1),
- ("value", 2),
- ],
- schema_id="mesh.runtime-policy.current",
- schema_name="mesh.runtime_policy",
- schema_version="mesh.runtime_policy.v1",
- )
- node = create_node(runtime_id="python-node")
- node.register_document(document)
- node.put(document, "policy:1", {
- "schema_version": "mesh.runtime_policy.v1",
- "name": "policy",
- "value": "old",
- })
- observed: list[CultMeshDatabaseChange] = []
- unsubscribe = node.database.watch_record(document, "policy:1", observed.append)
- try:
- applied = node.database.apply_snapshot_response({
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": "foreign-schema-snapshot",
- "documents": [
- {
- "schemaId": "sha256:foreign-mesh-runtime-policy",
- "recordKey": "policy:1",
- "storedAt": "2026-06-25T00:00:00Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload({
- "schema_version": "mesh.runtime_policy.v1",
- "name": "policy",
- "value": "new",
- }),
- },
- ],
- })
- finally:
- unsubscribe()
-
- local_schema_id = document.catalog_entry().schema_id
- self.assertEqual(applied[0].schema_id, local_schema_id)
- self.assertEqual(observed[0].schema_id, local_schema_id)
- self.assertEqual(observed[0].change_kind, "updated")
- self.assertEqual(observed[0].previous_value["value"], "old")
- self.assertEqual(observed[0].value["value"], "new")
- self.assertEqual(node.cache.get_required_envelope(document, "policy:1").schema_id, local_schema_id)
-
- def test_cultmesh_node_syncs_snapshot_and_shard_log_through_cultnet_client(self) -> None:
- import msgpack # type: ignore
- from cultnet_py import read_frame, write_frame
-
- document = define_database_entry_type(
- "mesh.sync_item",
- [
- ("name", 0),
- ("category", 1),
- ("value", 2, 0),
- ],
- cls=Item,
- )
- node = create_node(runtime_id="python-node")
- node.register_document(document)
- schema_id = document.catalog_entry().schema_id
- ready = threading.Event()
- server_error: list[BaseException] = []
- port_holder: list[int] = []
-
- def serve_requests() -> None:
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind(("127.0.0.1", 0))
- port_holder.append(server.getsockname()[1])
- server.listen(2)
- ready.set()
- for _ in range(2):
- connection, _ = server.accept()
- with connection:
- stream = connection.makefile("rwb")
- request = msgpack.unpackb(read_frame(stream), raw=False)
- if request["schemaVersion"] == "cultnet.snapshot_request.v0":
- response = {
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": request["messageId"],
- "documents": [
- {
- "schemaId": schema_id,
- "recordKey": "item:node",
- "storedAt": "2026-06-13T00:00:00Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload(Item("node", "mesh", 1)),
- }
- ],
- }
- elif request["schemaVersion"] == "cultnet.shard_log_request.v0":
- response = {
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": request["messageId"],
- "shardId": request["shardId"],
- "shardEpoch": request["shardEpoch"],
- "resyncRequired": False,
- "entries": [
- {
- "sequence": 1,
- "changeKind": "updated",
- "put": {
- "schemaVersion": "cultnet.document_put_raw.v0",
- "messageId": "put-node",
- "document": {
- "schemaId": schema_id,
- "recordKey": "item:node",
- "storedAt": "2026-06-13T00:00:01Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload(Item("node", "mesh", 9)),
- },
- "shardId": request["shardId"],
- "shardEpoch": request["shardEpoch"],
- },
- }
- ],
- }
- else:
- raise AssertionError(f"unexpected request {request['schemaVersion']}")
- write_frame(stream, msgpack.packb(response, use_bin_type=True))
- stream.flush()
- except BaseException as error:
- server_error.append(error)
- ready.set()
-
- thread = threading.Thread(target=serve_requests, daemon=True)
- thread.start()
- self.assertTrue(ready.wait(2.0))
- self.assertFalse(server_error)
-
- client = CultNetRawClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
- snapshot_changes = node.sync_snapshot(client, schema_ids=[schema_id])
- log_changes = node.sync_shard_log(client, shard_id="mesh", shard_epoch=1)
-
- thread.join(2.0)
- self.assertFalse(server_error)
- self.assertEqual(snapshot_changes[0].record_key, "item:node")
- self.assertEqual(log_changes[0].change_kind, "updated")
- self.assertEqual(node.get_required(document, "item:node").value, 9)
-
- def test_cultmesh_node_sync_document_returns_requested_alias_type(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.sync_alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.sync_alias_note.v1",
- schema_name="mesh.sync_alias_note",
- schema_version="mesh.sync_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.sync_alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.sync_alias_note.v1",
- schema_name="mesh.sync_alias_note",
- schema_version="mesh.sync_alias_note.v1",
- )
- node = create_node(runtime_id="python-node")
- node.register_document(document)
- requests: list[dict[str, Any]] = []
-
- class SnapshotClient:
- def fetch_snapshot_response(
- self,
- *,
- schema_ids: list[str] | None = None,
- record_keys: list[str] | None = None,
- shard_id: str | None = None,
- shard_epoch: int | None = None,
- ) -> dict[str, Any]:
- requests.append({
- "schema_ids": schema_ids,
- "record_keys": record_keys,
- "shard_id": shard_id,
- "shard_epoch": shard_epoch,
- })
- return {
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": "sync-document",
- "documents": [
- {
- "schemaId": document.catalog_entry().schema_id,
- "recordKey": "note:remote",
- "storedAt": "2026-06-27T00:00:00Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload(CanonicalNote("synced-alias")),
- }
- ],
- }
-
- synced = node.sync_document(SnapshotClient(), alias, "note:remote")
- facade_synced = CultMesh.sync_document(node, SnapshotClient(), alias, "note:remote")
-
- self.assertIsInstance(synced, UiNote)
- self.assertEqual(synced.body, "synced-alias")
- self.assertIsInstance(facade_synced, UiNote)
- self.assertEqual(facade_synced.body, "synced-alias")
- self.assertEqual(node.get_required(document, "note:remote").body, "synced-alias")
- self.assertIsInstance(node.get_required(alias, "note:remote"), UiNote)
- self.assertEqual(requests, [
- {
- "schema_ids": ["mesh.sync_alias_note.v1"],
- "record_keys": ["note:remote"],
- "shard_id": None,
- "shard_epoch": None,
- },
- {
- "schema_ids": ["mesh.sync_alias_note.v1"],
- "record_keys": ["note:remote"],
- "shard_id": None,
- "shard_epoch": None,
- },
- ])
-
- def test_cultmesh_node_sync_document_from_publication_returns_requested_alias_type(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.publication_alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.publication_alias_note.v1",
- schema_name="mesh.publication_alias_note",
- schema_version="mesh.publication_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.publication_alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.publication_alias_note.v1",
- schema_name="mesh.publication_alias_note",
- schema_version="mesh.publication_alias_note.v1",
- )
- node = create_node(runtime_id="python-publication-node")
- node.register_document(document)
- requests: list[dict[str, Any]] = []
-
- class SnapshotClient:
- def fetch_snapshot_response(
- self,
- *,
- schema_ids: list[str] | None = None,
- record_keys: list[str] | None = None,
- shard_id: str | None = None,
- shard_epoch: int | None = None,
- ) -> dict[str, Any]:
- requests.append({
- "schema_ids": schema_ids,
- "record_keys": record_keys,
- "shard_id": shard_id,
- "shard_epoch": shard_epoch,
- })
- return {
- "schemaVersion": "cultnet.snapshot_response_raw.v0",
- "messageId": "publication-sync-document",
- "documents": [
- {
- "schemaId": document.catalog_entry().schema_id,
- "recordKey": "note:published-peer",
- "storedAt": "2026-06-27T00:00:00Z",
- "payloadEncoding": "messagepack",
- "payload": document.encode_payload(CanonicalNote("peer-publication-alias")),
- }
- ],
- }
-
- source = CultMesh.publication_source_from_peer_snapshot(
- SnapshotClient(),
- shard_id="notes",
- shard_epoch=3,
- )
-
- synced = node.sync_document_from_publication(source, alias, "note:published-peer")
- facade_synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-peer")
-
- self.assertIsInstance(synced, UiNote)
- self.assertEqual(synced.body, "peer-publication-alias")
- self.assertIsInstance(facade_synced, UiNote)
- self.assertEqual(facade_synced.body, "peer-publication-alias")
- self.assertEqual(node.get_required(document, "note:published-peer").body, "peer-publication-alias")
- self.assertEqual(requests, [
- {
- "schema_ids": ["mesh.publication_alias_note.v1"],
- "record_keys": ["note:published-peer"],
- "shard_id": "notes",
- "shard_epoch": 3,
- },
- {
- "schema_ids": ["mesh.publication_alias_note.v1"],
- "record_keys": ["note:published-peer"],
- "shard_id": "notes",
- "shard_epoch": 3,
- },
- ])
-
- def test_cultmesh_node_sync_document_from_single_file_publication_uses_node_aliases(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.file_publication_alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.file_publication_alias_note.v1",
- schema_name="mesh.file_publication_alias_note",
- schema_version="mesh.file_publication_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.file_publication_alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.file_publication_alias_note.v1",
- schema_name="mesh.file_publication_alias_note",
- schema_version="mesh.file_publication_alias_note.v1",
- )
- with tempfile.TemporaryDirectory() as tmp:
- path = Path(tmp) / "publication.ccmp"
- writer = CultCache()
- writer.register_document_type(document)
- writer.add_generic_store(SingleFileMessagePackBackingStore(path))
- writer.put(document, "note:published-file", CanonicalNote("file-publication-alias"))
-
- node = create_node(runtime_id="python-file-publication-node")
- node.register_document(document)
- source = CultMesh.publication_source_from_single_file(path)
-
- synced = CultMesh.sync_document_from_publication(node, source, alias, "note:published-file")
-
- self.assertIsInstance(synced, UiNote)
- self.assertEqual(synced.body, "file-publication-alias")
- self.assertEqual(node.get_required(document, "note:published-file").body, "file-publication-alias")
- self.assertIsInstance(node.get_required(alias, "note:published-file"), UiNote)
-
- def test_cultmesh_node_sync_documents_from_publication_uses_binding_sources_and_aliases(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.catalog_publication_alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.catalog_publication_alias_note.v1",
- schema_name="mesh.catalog_publication_alias_note",
- schema_version="mesh.catalog_publication_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.catalog_publication_alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.catalog_publication_alias_note.v1",
- schema_name="mesh.catalog_publication_alias_note",
- schema_version="mesh.catalog_publication_alias_note.v1",
- )
- with tempfile.TemporaryDirectory() as tmp:
- first_path = Path(tmp) / "first.ccmp"
- second_path = Path(tmp) / "second.ccmp"
- first = CultCache()
- first.register_document_type(document)
- first.add_generic_store(SingleFileMessagePackBackingStore(first_path))
- first.put(document, "note:first", CanonicalNote("first catalog publication"))
- second = CultCache()
- second.register_document_type(document)
- second.add_generic_store(SingleFileMessagePackBackingStore(second_path))
- second.put(document, "note:second", CanonicalNote("second catalog publication"))
-
- node = create_node(runtime_id="python-catalog-publication-node")
- node.register_document(document)
- source = CultMesh.publication_source_from_single_file(first_path)
- bindings = [
- CultMesh.publication_document(alias, "note:first"),
- CultMesh.publication_document(
- alias,
- "note:second",
- source=CultMesh.publication_source_from_single_file(second_path),
- ),
- ]
-
- synced = node.sync_documents_from_publication(source, bindings)
- facade_synced = CultMesh.sync_documents_from_publication(node, source, bindings)
-
- self.assertEqual([value.body for value in synced], [
- "first catalog publication",
- "second catalog publication",
- ])
- self.assertTrue(all(isinstance(value, UiNote) for value in synced))
- self.assertEqual([value.body for value in facade_synced], [
- "first catalog publication",
- "second catalog publication",
- ])
- self.assertEqual(node.get_required(document, "note:first").body, "first catalog publication")
- self.assertEqual(node.get_required(document, "note:second").body, "second catalog publication")
- self.assertIsInstance(node.get_required(alias, "note:second"), UiNote)
-
- def test_cultmesh_node_emits_raw_put_and_delete_messages_for_local_writes(self) -> None:
- document = define_database_entry_type(
- "mesh.emit_item",
- [
- ("name", 0),
- ("category", 1),
- ("value", 2, 0),
- ],
- cls=Item,
- )
- node = create_node(runtime_id="python-node")
- node.register_document(document)
-
- put = node.put_raw_message(
- document,
- "item:emit",
- Item("wand", "gear", 4),
- message_id="put-emit",
- shard_id="mesh",
- shard_epoch=1,
- ).to_wire()
- self.assertEqual(node.get_required(document, "item:emit").name, "wand")
- self.assertEqual(put["schemaVersion"], "cultnet.document_put_raw.v0")
- self.assertEqual(put["messageId"], "put-emit")
- self.assertEqual(put["document"]["schemaId"], document.catalog_entry().schema_id)
- self.assertEqual(put["document"]["recordKey"], "item:emit")
- self.assertEqual(put["document"]["sourceRuntimeId"], "python-node")
- self.assertEqual(put["shardId"], "mesh")
- self.assertEqual(put["shardEpoch"], 1)
-
- delete = node.delete_raw_message(
- document,
- "item:emit",
- message_id="delete-emit",
- shard_id="mesh",
- shard_epoch=1,
- ).to_wire()
- self.assertIsNone(node.get(document, "item:emit"))
- self.assertEqual(delete["schemaVersion"], "cultnet.document_delete.v0")
- self.assertEqual(delete["messageId"], "delete-emit")
- self.assertEqual(delete["schemaId"], document.catalog_entry().schema_id)
- self.assertEqual(delete["recordKey"], "item:emit")
- self.assertEqual(delete["shardId"], "mesh")
- self.assertEqual(delete["shardEpoch"], 1)
-
- def test_cultmesh_node_resolves_same_schema_aliases_for_local_state(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.alias_note.v1",
- schema_name="mesh.alias_note",
- schema_version="mesh.alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.alias_note.v1",
- schema_name="mesh.alias_note",
- schema_version="mesh.alias_note.v1",
- )
- node = create_node(runtime_id="python-node")
- node.register_document(document)
- seen: list[CultMeshDatabaseChange] = []
- all_seen: list[CultMeshDatabaseChange] = []
- node.database.watch(all_seen.append, document=alias)
- node.database.watch_record(alias, "note:alias", seen.append)
-
- node.put(alias, "note:alias", UiNote("from-alias-put"))
- self.assertEqual(node.get_required(document, "note:alias").body, "from-alias-put")
- alias_value = node.get_required(alias, "note:alias")
- self.assertIsInstance(alias_value, UiNote)
- self.assertEqual(alias_value.body, "from-alias-put")
- alias_collection = node.get_all(alias)
- self.assertEqual(len(alias_collection), 1)
- self.assertIsInstance(alias_collection[0], UiNote)
- self.assertEqual(alias_collection[0].body, "from-alias-put")
- self.assertEqual(seen[0].document.type, alias.type)
- self.assertEqual(seen[0].schema_id, "mesh.alias_note.v1")
- self.assertIsInstance(seen[0].value, UiNote)
- self.assertEqual(seen[0].value.body, "from-alias-put")
- self.assertEqual(all_seen[0].document.type, alias.type)
- self.assertIsInstance(all_seen[0].value, UiNote)
- self.assertEqual(all_seen[0].value.body, "from-alias-put")
-
- node.put(document, "note:alias", CanonicalNote("from-canonical-put"))
- self.assertEqual(seen[1].document.type, alias.type)
- self.assertIsInstance(seen[1].value, UiNote)
- self.assertIsInstance(seen[1].previous_value, UiNote)
- self.assertEqual(seen[1].value.body, "from-canonical-put")
- self.assertEqual(seen[1].previous_value.body, "from-alias-put")
-
- put = node.put_raw_message(
- alias,
- "note:alias",
- UiNote("from-alias-raw-put"),
- message_id="alias-put",
- ).to_wire()
- self.assertEqual(put["document"]["schemaId"], "mesh.alias_note.v1")
- self.assertEqual(put["document"]["recordKey"], "note:alias")
- self.assertEqual(node.get_required(alias, "note:alias").body, "from-alias-raw-put")
-
- delete = node.delete_raw_message(alias, "note:alias", message_id="alias-delete").to_wire()
- self.assertEqual(delete["schemaId"], "mesh.alias_note.v1")
- self.assertIsNone(node.get(alias, "note:alias"))
- self.assertEqual(node.get_all(alias), [])
-
- def test_cultmesh_reactive_document_coalesces_direct_alias_member_writes(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
- revision: int
-
- @dataclass
- class UiNote:
- body: str
- revision: int
-
- document = define_database_entry_type(
- "mesh.reactive_alias_note",
- [("body", 0), ("revision", 1)],
- cls=CanonicalNote,
- schema_id="mesh.reactive_alias_note.v1",
- schema_name="mesh.reactive_alias_note",
- schema_version="mesh.reactive_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.reactive_alias_note.ui",
- [("body", 0), ("revision", 1)],
- cls=UiNote,
- schema_id="mesh.reactive_alias_note.v1",
- schema_name="mesh.reactive_alias_note",
- schema_version="mesh.reactive_alias_note.v1",
- )
- node = create_node(runtime_id="python-reactive")
- node.register_document(document)
- node.put(document, "note:reactive", CanonicalNote("initial", 1))
- seen: list[CultMeshDatabaseChange] = []
- node.database.watch_record(alias, "note:reactive", seen.append)
-
- reactive = node.reactive_document(
- alias,
- "note:reactive",
- CultMeshReactiveDocumentOptions(flush_delay_seconds=0.02),
- )
- try:
- self.assertIsInstance(reactive.current, UiNote)
- reactive.current.body = "first-local-edit"
- reactive.current.body = "second-local-edit"
- reactive.current.revision = 2
-
- self._wait_until(lambda: node.get_required(alias, "note:reactive").body == "second-local-edit")
-
- self.assertFalse(reactive.is_dirty)
- self.assertEqual(node.get_required(document, "note:reactive").revision, 2)
- self.assertEqual(seen[-1].value.body, "second-local-edit")
- finally:
- reactive.dispose()
-
- def test_cultmesh_reactive_document_tracks_canonical_reconciliation_delta(self) -> None:
- @dataclass
- class Note:
- body: str
- revision: int
-
- document = define_database_entry_type(
- "mesh.reactive_reconcile_note",
- [("body", 0), ("revision", 1)],
- cls=Note,
- schema_id="mesh.reactive_reconcile_note.v1",
- schema_name="mesh.reactive_reconcile_note",
- schema_version="mesh.reactive_reconcile_note.v1",
- )
- node = create_node(runtime_id="python-reactive-reconcile")
- node.register_document(document)
- node.put(document, "note:reconcile", Note("initial", 1))
-
- reactive = CultMesh.reactive_document(
- node,
- document,
- "note:reconcile",
- CultMeshReactiveDocumentOptions(flush_delay_seconds=60),
- )
- try:
- reactive.update(lambda note: setattr(note, "body", "local-prediction"))
- reactive.current.revision = 2
-
- node.put(document, "note:reconcile", Note("authoritative", 7))
-
- self.assertEqual(reactive.current.body, "local-prediction")
- self.assertIsNotNone(reactive.reconciliation)
- self.assertEqual(reactive.reconciliation.canonical.body, "authoritative")
- self.assertEqual(reactive.reconciliation.predicted.body, "local-prediction")
- self.assertEqual(reactive.reconciliation.delta["body"], "local-prediction")
- self.assertEqual(reactive.reconciliation.delta["revision"], -5)
-
- reactive.flush()
- self.assertEqual(node.get_required(document, "note:reconcile").body, "local-prediction")
- self.assertIsNone(reactive.reconciliation)
- finally:
- reactive.dispose()
-
- def test_cultnet_document_delete_helper_matches_schema_v0_shape(self) -> None:
- message = document_delete(
- message_id="delete-1",
- schema_id="schema-a",
- record_key="record-a",
- shard_id="interop",
- shard_epoch=1,
- ).to_wire()
- self.assertEqual(message["schemaVersion"], "cultnet.document_delete.v0")
- self.assertEqual(message["messageId"], "delete-1")
- self.assertEqual(message["schemaId"], "schema-a")
- self.assertEqual(message["recordKey"], "record-a")
- self.assertEqual(message["shardId"], "interop")
- self.assertEqual(message["shardEpoch"], 1)
-
- def test_cultnet_auth_session_helpers_match_schema_v0_shape(self) -> None:
- self.assertEqual(
- login(nonce="nonce", auth="encrypted-auth", password="encrypted-password").to_wire(),
- {
- "schemaVersion": "cultnet.login.v0",
- "nonce": "nonce",
- "auth": "encrypted-auth",
- "password": "encrypted-password",
- },
- )
- self.assertEqual(
- register(
- nonce="nonce",
- email="encrypted-email",
- password="encrypted-password",
- name="encrypted-name",
- ).to_wire(),
- {
- "schemaVersion": "cultnet.register.v0",
- "nonce": "nonce",
- "email": "encrypted-email",
- "password": "encrypted-password",
- "name": "encrypted-name",
- },
- )
- self.assertEqual(
- verify_session(nonce="nonce", session="encrypted-session").to_wire(),
- {
- "schemaVersion": "cultnet.verify.v0",
- "nonce": "nonce",
- "session": "encrypted-session",
- },
- )
- self.assertEqual(
- login_success(nonce="nonce", session="encrypted-session").to_wire(),
- {
- "schemaVersion": "cultnet.login_success.v0",
- "nonce": "nonce",
- "session": "encrypted-session",
- },
- )
-
- def test_cultnet_wire_catalog_describes_python_handled_messages(self) -> None:
- descriptors = wire_message_schema_descriptors(include_schema_json=True)
- by_version = {descriptor["schemaVersion"]: descriptor for descriptor in descriptors}
- for schema_version in [
- "cultnet.error.v0",
- "cultnet.login.v0",
- "cultnet.register.v0",
- "cultnet.verify.v0",
- "cultnet.login_success.v0",
- "cultnet.transport_profile.v0",
- "cultnet.document_delete.v0",
- "cultnet.database_change_raw.v0",
- "cultnet.shard_log_response.v0",
- "cultnet.simulation_consensus_candidate.v0",
- "cultmesh.peer_exchange_response.v0",
- ]:
- self.assertIn(schema_version, by_version)
- expected_kind = "shared_contract" if schema_version == "cultnet.transport_profile.v0" else "wire_message"
- self.assertEqual(by_version[schema_version]["kind"], expected_kind)
- self.assertIn("cultnet.schema.v0", by_version[schema_version]["wireContracts"])
- self.assertIn(schema_version, by_version[schema_version]["schemaJson"])
- self.assertEqual(len(by_version[schema_version]["contentHash"]), 64)
-
- delete_schema = json.loads(by_version["cultnet.document_delete.v0"]["schemaJson"])
- self.assertEqual(
- delete_schema["required"],
- ["schemaVersion", "messageId", "schemaId", "recordKey"],
- )
- self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string")
-
- error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"])
- self.assertEqual(error_schema["required"], ["schemaVersion", "error"])
- self.assertEqual(error_schema["properties"]["error"]["type"], "string")
- self.assertIn("details", error_schema["properties"])
-
- login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"])
- self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"])
- self.assertEqual(login_schema["properties"]["auth"]["type"], "string")
-
- register_schema = json.loads(by_version["cultnet.register.v0"]["schemaJson"])
- self.assertEqual(register_schema["required"], ["schemaVersion", "nonce", "email", "password", "name"])
- self.assertEqual(register_schema["properties"]["email"]["type"], "string")
-
- verify_schema = json.loads(by_version["cultnet.verify.v0"]["schemaJson"])
- self.assertEqual(verify_schema["required"], ["schemaVersion", "nonce", "session"])
- self.assertEqual(verify_schema["properties"]["session"]["type"], "string")
-
- success_schema = json.loads(by_version["cultnet.login_success.v0"]["schemaJson"])
- self.assertEqual(success_schema["required"], ["schemaVersion", "nonce", "session"])
-
- transport_profile_schema = json.loads(by_version["cultnet.transport_profile.v0"]["schemaJson"])
- self.assertEqual(transport_profile_schema["required"], ["schemaVersion", "runtimeId", "transports"])
- transport = transport_profile_schema["properties"]["transports"]["items"]
- self.assertEqual(transport["properties"]["protocol"]["enum"], ["tcp_framed", "litenetlib", "websocket", "rudp"])
- self.assertEqual(
- transport["properties"]["reconnectPolicy"]["properties"]["schemaVersion"]["const"],
- "cultnet.reconnect_policy.v0",
- )
- channel = transport["properties"]["channels"]["items"]
- self.assertEqual(channel["properties"]["ordering"]["enum"], ["ordered", "unordered", "sequenced"])
-
- change_schema = json.loads(by_version["cultnet.database_change_raw.v0"]["schemaJson"])
- self.assertEqual(change_schema["properties"]["changeKind"]["enum"], ["added", "updated", "removed"])
- self.assertIn("document", change_schema["properties"])
- self.assertIn("schemaId", change_schema["properties"])
- self.assertIn("recordKey", change_schema["properties"])
-
- log_schema = json.loads(by_version["cultnet.shard_log_response.v0"]["schemaJson"])
- self.assertIn("entries", log_schema["required"])
- self.assertIn("resyncRequired", log_schema["required"])
- self.assertIn("compactedThrough", log_schema["properties"])
-
- hello_schema = json.loads(by_version["cultnet.hello.v0"]["schemaJson"])
- mutation_contract = hello_schema["properties"]["supportedMutationContracts"]["items"]
- self.assertEqual(mutation_contract["type"], "object")
- self.assertIn("operations", mutation_contract["properties"])
- self.assertEqual(
- hello_schema["properties"]["transportProfiles"]["items"]["$ref"],
- "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
- )
-
- consensus_schema = json.loads(by_version["cultnet.simulation_consensus_candidate.v0"]["schemaJson"])
- for required_field in ["witnessCount", "supportWeight", "totalWeight", "hasQuorum", "confidence"]:
- self.assertIn(required_field, consensus_schema["required"])
-
- def test_cultnet_schema_catalog_applies_filters_and_responses(self) -> None:
- catalog = wire_message_schema_catalog(include_schema_json=True)
- descriptor = catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json")
- self.assertIsNotNone(descriptor)
- assert descriptor is not None
- self.assertEqual(descriptor.kind, "wire_message")
- self.assertEqual(descriptor.schema_version, "cultnet.document_put_raw.v0")
- self.assertIsNotNone(descriptor.schema_json)
-
- filtered = catalog.list(kinds=["wire_message"], schema_ids=[descriptor.schema_id])
- self.assertEqual(filtered, [descriptor])
-
- response = catalog.create_response(
- message_id="catalog-response",
- include_schema_json=False,
- schema_ids=[descriptor.schema_id],
- kinds=["wire_message"],
- )
- self.assertEqual(response["schemaVersion"], "cultnet.schema_catalog_response.v0")
- self.assertNotIn("schemaJson", response["schemas"][0])
-
- remote = CultNetSchemaCatalog()
- applied = remote.apply_response({
- "schemaVersion": "cultnet.schema_catalog_response.v0",
- "messageId": "remote",
- "schemas": [
- CultNetSchemaDescriptor(
- schema_id="schema:custom",
- kind="shared_contract",
- schema_version="custom.v0",
- wire_contracts=("cultnet.schema.v0",),
- content_hash="hash",
- schema_json="{}",
- ).to_wire()
- ],
- })
- self.assertEqual(applied[0].schema_id, "schema:custom")
- self.assertEqual(remote.get("schema:custom"), applied[0])
-
- def test_cultnet_shard_helpers_match_schema_v0_shape(self) -> None:
- catalog = shard_catalog_request(
- message_id="catalog-1",
- schema_ids=["schema-a"],
- record_keys=["record-a"],
- ).to_wire()
- self.assertEqual(catalog["schemaVersion"], "cultnet.shard_catalog_request.v0")
- self.assertEqual(catalog["messageId"], "catalog-1")
- self.assertEqual(catalog["schemaIds"], ["schema-a"])
- self.assertEqual(catalog["recordKeys"], ["record-a"])
-
- log = shard_log_request(
- message_id="log-1",
- shard_id="interop",
- shard_epoch=7,
- after_sequence=3,
- limit=2,
- ).to_wire()
- self.assertEqual(log["schemaVersion"], "cultnet.shard_log_request.v0")
- self.assertEqual(log["messageId"], "log-1")
- self.assertEqual(log["shardId"], "interop")
- self.assertEqual(log["shardEpoch"], 7)
- self.assertEqual(log["afterSequence"], 3)
- self.assertEqual(log["limit"], 2)
-
- def test_cultnet_shard_catalog_applies_filters_and_responses(self) -> None:
- catalog = CultNetShardCatalog()
- primary = catalog.upsert(CultNetShardDescriptor(
- shard_id="notes",
- owner_runtime_id="python-runtime",
- epoch=2,
- is_primary=True,
- schema_ids=("schema-note.v1",),
- key_prefix="note:",
- primary_endpoints=("cultnet://127.0.0.1:3075",),
- read_replica_endpoints=("cultnet://127.0.0.1:3075",),
- region="local",
- ))
- catalog.upsert({
- "shardId": "facts",
- "ownerRuntimeId": "python-runtime",
- "epoch": 1,
- "schemaIds": ["schema-fact"],
- "keyPrefix": "fact:",
- "primaryEndpoints": ["cultnet://127.0.0.1:3076"],
- })
-
- self.assertTrue(primary.serves(schema_id="schema-note.v1", record_key="note:1"))
- self.assertTrue(primary.serves(schema_id="schema-note", record_key="note:1"))
- self.assertFalse(primary.serves(schema_id="schema-note", record_key="fact:1"))
- self.assertEqual(catalog.list(schema_ids=["schema-note.v1"]), [primary])
- self.assertEqual(catalog.list(schema_ids=["schema-note"]), [primary])
- self.assertEqual(catalog.list(record_keys=["note:1"]), [primary])
-
- response = catalog.create_response(
- message_id="shards",
- schema_ids=["schema-note"],
- record_keys=["note:1"],
- )
- self.assertEqual(response["schemaVersion"], "cultnet.shard_catalog_response.v0")
- self.assertEqual(response["shards"][0]["shardId"], "notes")
- self.assertEqual(response["shards"][0]["ownerRuntimeId"], "python-runtime")
-
- remote = CultNetShardCatalog()
- applied = remote.apply_response(response)
- self.assertEqual(applied[0].shard_id, "notes")
- self.assertEqual(remote.get("notes"), applied[0])
-
- def test_cultnet_shard_log_response_tracks_cursor_and_resync_state(self) -> None:
- response = CultNetShardLogResponse.from_wire({
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "log",
- "shardId": "notes",
- "shardEpoch": 2,
- "entries": [
- CultNetShardLogEntry(
- sequence=2,
- change_kind="updated",
- put={"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "put", "document": {"schemaId": "schema-note", "recordKey": "note:1", "payload": b"p"}},
- committed_at="2026-06-14T00:00:00Z",
- ).to_wire(),
- {"sequence": 3, "changeKind": "removed", "delete": {"schemaId": "schema-note", "recordKey": "note:1"}},
- ],
- "resyncRequired": False,
- })
- self.assertEqual(response.last_sequence, 3)
- self.assertFalse(response.resync_required)
- self.assertEqual(response.entries[0].put["document"]["recordKey"], "note:1")
- self.assertIsInstance(response.entries[0].raw_document, CultNetRawDocumentRecord)
- self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
- self.assertEqual(response.entries[1].delete_schema_id, "schema-note")
- self.assertEqual(response.entries[1].delete_record_key, "note:1")
- self.assertEqual(response.to_wire()["entries"][1]["delete"]["recordKey"], "note:1")
- self.assertIs(response.require_usable(), response)
-
- resync = CultNetShardLogResponse.from_wire({
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "log-resync",
- "shardId": "notes",
- "shardEpoch": 2,
- "entries": [],
- "resyncRequired": True,
- "reason": "compacted",
- "compactedThrough": 12,
- })
- self.assertEqual(resync.last_sequence, 12)
- with self.assertRaisesRegex(ValueError, "compacted"):
- resync.require_usable()
-
- def test_cultnet_simulation_observation_helper_matches_schema_v0_shape(self) -> None:
- claim_hash = compute_simulation_claim_hash("frame:42", "subject:player-1", "hit")
- self.assertEqual(len(claim_hash), 64)
- message = simulation_observation(
- message_id="obs-1",
- witness_runtime_id="python-test",
- shard_id="interop",
- shard_epoch=1,
- frame=42,
- subject_id="player-1",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="player-1 hit target-a",
- observed_at="2026-06-13T00:00:02Z",
- ).to_wire()
- self.assertEqual(message["schemaVersion"], "cultnet.simulation_observation.v0")
- self.assertEqual(message["messageId"], "obs-1")
- self.assertEqual(message["observation"]["witnessRuntimeId"], "python-test")
- self.assertEqual(message["observation"]["claimHash"], claim_hash)
- self.assertEqual(message["observation"]["weight"], 1.0)
- observation = CultNetSimulationObservation.from_wire(message)
- self.assertEqual(observation.witness_runtime_id, "python-test")
- self.assertEqual(observation.claim_summary, "player-1 hit target-a")
- self.assertEqual(observation.to_message_wire(message_id="obs-2")["observation"], message["observation"])
-
- def test_cultnet_witness_artifact_bundle_uses_csharp_slot_order(self) -> None:
- import msgpack # type: ignore
-
- bundle = witness_artifact_bundle(
- bundle_id="bundle-1",
- witness_kind="interop-proof",
- captured_at="2026-06-13T00:00:03Z",
- subject={"documentType": "cultnet.interop-note", "subjectId": "note:python"},
- contracts=[{"role": "payload", "schemaId": "schema-a"}],
- artifacts=[{"role": "log", "uri": "cultcache://bundle-1/log", "mediaType": "text/plain"}],
- timing_witnesses=[{"stage": "roundtrip", "startedAt": "2026-06-13T00:00:03Z", "completedAt": "2026-06-13T00:00:04Z", "latencyMs": 1.0}],
- provenance={"pipelineId": "interop", "runId": "run-1", "runtimeId": "python-test"},
- )
- payload = encode_witness_artifact_bundle_payload(bundle)
- decoded = decode_witness_artifact_bundle_payload(payload)
- typed = CultNetWitnessArtifactBundle.from_wire(bundle)
- typed_payload = typed.to_payload()
- typed_decoded = CultNetWitnessArtifactBundle.from_payload(typed_payload)
- slots = msgpack.unpackb(payload, raw=False)
-
- self.assertEqual(payload, typed_payload)
- self.assertEqual(slots[0], "bundle-1")
- self.assertEqual(slots[1], "interop-proof")
- self.assertEqual(slots[2], "2026-06-13T00:00:03Z")
- self.assertEqual(slots[3]["subjectId"], "note:python")
- self.assertEqual(slots[4][0]["schemaId"], "schema-a")
- self.assertEqual(slots[5][0]["uri"], "cultcache://bundle-1/log")
- self.assertEqual(slots[6][0]["latencyMs"], 1.0)
- self.assertEqual(slots[7]["runtimeId"], "python-test")
- self.assertEqual(decoded["bundleId"], "bundle-1")
- self.assertEqual(decoded["witnessKind"], "interop-proof")
- self.assertEqual(decoded["subject"]["subjectId"], "note:python")
- self.assertEqual(decoded["contracts"][0]["schemaId"], "schema-a")
- self.assertEqual(decoded["artifacts"][0]["mediaType"], "text/plain")
- self.assertEqual(decoded["provenance"]["runtimeId"], "python-test")
- self.assertEqual(typed.bundle_id, "bundle-1")
- self.assertEqual(typed_decoded.to_wire(), bundle)
-
- def test_cultmesh_node_uses_cultcache_store(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- document = define_database_entry_type("mesh.note", [("body", 0)])
- store_path = Path(tmp) / "mesh.cc"
- node = create_node(store_path, runtime_id="mesh-test")
- self.assertIsInstance(node.database, CultMeshDatabase)
- node.database.register_document(document)
- node.database.put(document, "note:1", {"body": "hello"})
- self.assertEqual(node.get_required(document, "note:1")["body"], "hello")
-
- reopened = create_node(store_path, runtime_id="mesh-test")
- reopened.database.register_document(document)
- reopened.database.pull()
- self.assertEqual(reopened.database.get(document, "note:1")["body"], "hello")
-
- def test_cultmesh_node_options_attach_default_durable_shard_log_store(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- document = define_database_entry_type(
- "mesh.durable_option_note",
- [("body", 0)],
- schema_id="mesh.durable_option_note.v1",
- )
- cache_path = Path(tmp) / "world.cc"
- node = CultMesh.create_node(
- cache_path,
- runtime_id="durable-option-primary",
- options=CultMeshNodeOptions(enable_durable_shard_logs=True),
- )
- node.database.register_document(document)
- node.database.put_raw_message(document, "note:1", {"body": "stored"}, shard_id="notes", shard_epoch=5)
-
- log_path = Path(tmp) / "world.cultmesh" / "shard-logs"
- self.assertTrue(log_path.exists())
-
- reopened = CultMesh.start_node(
- cache_path,
- runtime_id="durable-option-primary",
- enable_durable_shard_logs=True,
- )
- reopened.database.register_document(document)
- response = reopened.database.build_shard_log_response(shard_id="notes", shard_epoch=5)
-
- self.assertEqual(reopened.database.shard_ids(), ["notes"])
- self.assertEqual([entry.sequence for entry in response.entries], [1])
- self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
-
- def test_cultmesh_database_watchers_observe_local_changes_and_unsubscribe(self) -> None:
- document = define_database_entry_type("mesh.watch", [("body", 0)])
- node = CultMesh.create_node(runtime_id="mesh-watch")
- node.database.register_document(document)
- changes: list[CultMeshDatabaseChange] = []
- all_changes: list[CultMeshDatabaseChange] = []
-
- unsubscribe_record = node.database.watch_record(document, "note:1", changes.append)
- unsubscribe_all = node.database.watch(all_changes.append, document=document)
-
- node.database.put(document, "note:1", {"body": "hello"})
- node.database.put(document, "note:2", {"body": "skip"})
- node.database.put(document, "note:1", {"body": "updated"})
- node.database.delete(document, "note:1")
- unsubscribe_record()
- node.database.put(document, "note:1", {"body": "after-unsubscribe"})
- unsubscribe_all()
-
- self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"])
- self.assertEqual([change.record_key for change in changes], ["note:1", "note:1", "note:1"])
- self.assertEqual(changes[1].previous_value, {"body": "hello"})
- self.assertIsNone(changes[2].value)
- self.assertEqual([change.record_key for change in all_changes], ["note:1", "note:2", "note:1", "note:1", "note:1"])
-
- def test_cultmesh_database_register_document_is_idempotent(self) -> None:
- document = define_database_entry_type("mesh.idempotent", [("body", 0)])
- conflicting = define_database_entry_type("mesh.idempotent", [("title", 0)])
- node = CultMesh.create_node(runtime_id="mesh-idempotent")
-
- node.database.register_document(document)
- node.database.register_document(document)
- node.database.put(document, "note:1", {"body": "still registered once"})
-
- self.assertEqual(node.database.get_required(document, "note:1")["body"], "still registered once")
- self.assertEqual([registered.type for registered in node.documents], ["mesh.idempotent"])
- with self.assertRaises(ValueError):
- node.database.register_document(conflicting)
-
- def test_cultmesh_database_global_document_facade_uses_singleton_key_and_watchers(self) -> None:
- document = define_database_entry_type(
- "mesh.global_settings",
- [("theme", 0)],
- global_document=True,
- )
- node = CultMesh.create_node(runtime_id="mesh-global")
- node.database.register_document(document)
- changes: list[CultMeshDatabaseChange] = []
-
- node.database.watch_global(document, changes.append)
- node.database.put_global(document, {"theme": "ash"})
- node.put_global(document, {"theme": "ember"})
- node.delete_global(document)
-
- self.assertIsNone(node.database.get_global(document))
- self.assertEqual([change.record_key for change in changes], [node.cache.GLOBAL_KEY] * 3)
- self.assertEqual([change.change_kind for change in changes], ["added", "updated", "removed"])
- self.assertEqual(changes[1].previous_value, {"theme": "ash"})
- self.assertEqual(changes[2].previous_value, {"theme": "ember"})
- with self.assertRaises(Exception):
- node.database.put(document, "settings:wrong", {"theme": "bad"})
-
- def test_cultmesh_database_creates_raw_snapshot_response_from_envelopes(self) -> None:
- document = define_database_entry_type(
- "mesh.snapshot_note",
- [("body", 0)],
- schema_id="mesh.snapshot_note.v1",
- )
- source = CultMesh.create_node(runtime_id="mesh-snapshot-source")
- source.database.register_document(document)
- source.database.put(document, "note:1", {"body": "include"})
- source.database.put(document, "note:2", {"body": "skip"})
-
- typed_response = source.database.build_snapshot_response(
- message_id="snapshot-1",
- schema_ids=["mesh.snapshot_note.v1"],
- record_keys=["note:1"],
- shard_id="notes",
- shard_epoch=4,
- shard_log_sequence=7,
- )
- response = source.database.create_snapshot_response(
- message_id="snapshot-1",
- schema_ids=["mesh.snapshot_note.v1"],
- record_keys=["note:1"],
- shard_id="notes",
- shard_epoch=4,
- shard_log_sequence=7,
- )
-
- self.assertIsInstance(typed_response, CultNetRawSnapshotResponse)
- self.assertEqual(typed_response.message_id, "snapshot-1")
- self.assertEqual(typed_response.documents[0].record_key, "note:1")
- self.assertEqual(typed_response.to_wire(), response)
- self.assertEqual(source.build_snapshot_response(schema_ids=["mesh.snapshot_note.v1"]).documents[0].record_key, "note:1")
- self.assertEqual(response["schemaVersion"], "cultnet.snapshot_response_raw.v0")
- self.assertEqual(response["messageId"], "snapshot-1")
- self.assertEqual(response["shardId"], "notes")
- self.assertEqual(response["shardEpoch"], 4)
- self.assertEqual(response["shardLogSequence"], 7)
- self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:1"])
- self.assertEqual(response["documents"][0]["schemaId"], "mesh.snapshot_note.v1")
- self.assertEqual(response["documents"][0]["payloadEncoding"], "messagepack")
-
- target = CultMesh.create_node(runtime_id="mesh-snapshot-target")
- target.database.register_document(document)
- applied = target.database.apply_snapshot_response(response)
- self.assertEqual([(record.schema_id, record.record_key) for record in applied], [("mesh.snapshot_note.v1", "note:1")])
- self.assertEqual(target.database.get_required(document, "note:1"), {"body": "include"})
- self.assertIsNone(target.database.get(document, "note:2"))
-
- typed_target = CultMesh.create_node(runtime_id="mesh-snapshot-typed-target")
- typed_target.database.register_document(document)
- typed_applied = typed_target.database.apply_snapshot_response(typed_response)
- self.assertEqual([(record.schema_id, record.record_key) for record in typed_applied], [("mesh.snapshot_note.v1", "note:1")])
- self.assertEqual(typed_target.database.get_required(document, "note:1"), {"body": "include"})
-
- def test_cultmesh_database_filters_snapshot_by_logged_shard_membership(self) -> None:
- document = define_database_entry_type(
- "mesh.sharded_snapshot_note",
- [("body", 0)],
- schema_id="mesh.sharded_snapshot_note.v1",
- )
- source = CultMesh.create_node(runtime_id="mesh-sharded-snapshot-source")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:notes", {"body": "include"}, shard_id="notes", shard_epoch=2)
- source.database.put_raw_message(document, "note:other", {"body": "skip"}, shard_id="other", shard_epoch=1)
-
- response = source.database.build_snapshot_response(
- schema_ids=["mesh.sharded_snapshot_note.v1"],
- shard_id="notes",
- shard_epoch=2,
- )
-
- self.assertEqual(response.shard_id, "notes")
- self.assertEqual(response.shard_epoch, 2)
- self.assertEqual([record.record_key for record in response.documents], ["note:notes"])
-
- def test_python_interop_peer_filters_shard_snapshot_by_logged_membership(self) -> None:
- runtime_id = f"python-interop-test-{uuid4().hex}"
- state = build_state(
- runtime_id=runtime_id,
- runtime_kind="python",
- display_name="Python Interop Test",
- agent_id="python-interop-test-agent",
- schema_path=str(Path("packages/cultnet-ts/integration/contracts/cultnet.interop-note.schema.json")),
- )
- binding = state.bindings["note"]
- logged_value = {
- "schemaVersion": "cultnet.interop_note.v0",
- "documentId": "note:logged",
- "authorRuntimeId": runtime_id,
- "title": "Logged",
- "body": "This record belongs to the shard log.",
- "tags": ["interop", "logged"],
- }
- unlogged_value = {
- **logged_value,
- "documentId": "note:unlogged",
- "title": "Unlogged",
- "body": "This record is in cache but not in the shard log.",
- }
- state.cache.put(binding.document, "note:logged", logged_value)
- state.cache.put(binding.document, "note:unlogged", unlogged_value)
- logged_record = {
- "schemaId": state.note_schema_id,
- "recordKey": "note:logged",
- "storedAt": "2026-06-14T00:00:00Z",
- "payloadEncoding": "messagepack",
- "payload": binding.document.encode_payload(logged_value),
- }
- append_shard_log_put(
- state,
- {"messageId": "logged-put"},
- logged_record,
- )
-
- response = raw_snapshot_response(
- state,
- {
- "schemaVersion": "cultnet.snapshot_request.v0",
- "messageId": "interop-shard-snapshot",
- "schemaIds": [state.note_schema_id],
- "shardId": state.shard_id,
- },
- )
-
- self.assertEqual(response["shardId"], state.shard_id)
- self.assertEqual(response["shardLogSequence"], 1)
- self.assertEqual([record["recordKey"] for record in response["documents"]], ["note:logged"])
-
- def test_cultmesh_database_creates_shard_log_response_from_raw_mutations(self) -> None:
- document = define_database_entry_type(
- "mesh.log_note",
- [("body", 0)],
- schema_id="mesh.log_note.v1",
- )
- source = CultMesh.create_node(runtime_id="mesh-log-source")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="notes", shard_epoch=3)
- source.database.put_raw_message(document, "note:1", {"body": "second"}, shard_id="notes", shard_epoch=3)
- source.database.put_raw_message(document, "note:2", {"body": "other"}, shard_id="other", shard_epoch=1)
- source.database.delete_raw_message(document, "note:1", shard_id="notes", shard_epoch=3)
-
- self.assertEqual(source.database.shard_ids(), ["notes", "other"])
- self.assertEqual(source.database.shard_schema_ids("notes"), ["mesh.log_note.v1"])
-
- typed_response = source.database.build_shard_log_response(
- message_id="log-1",
- shard_id="notes",
- shard_epoch=3,
- after_sequence=1,
- limit=2,
- )
- response = source.database.create_shard_log_response(
- message_id="log-1",
- shard_id="notes",
- shard_epoch=3,
- after_sequence=1,
- limit=2,
- )
-
- self.assertIsInstance(typed_response, CultNetShardLogResponse)
- self.assertEqual(typed_response.message_id, "log-1")
- self.assertEqual([entry.sequence for entry in typed_response.entries], [2, 3])
- self.assertEqual(typed_response.to_wire(), response)
- self.assertEqual([entry.sequence for entry in source.build_shard_log_response(shard_id="notes").entries], [1, 2, 3])
- self.assertEqual(response["schemaVersion"], "cultnet.shard_log_response.v0")
- self.assertEqual(response["messageId"], "log-1")
- self.assertEqual(response["shardId"], "notes")
- self.assertEqual(response["shardEpoch"], 3)
- self.assertFalse(response["resyncRequired"])
- self.assertEqual([entry["sequence"] for entry in response["entries"]], [2, 3])
- self.assertEqual([entry["changeKind"] for entry in response["entries"]], ["updated", "removed"])
- self.assertEqual(response["entries"][0]["put"]["document"]["recordKey"], "note:1")
- self.assertEqual(response["entries"][1]["delete"]["recordKey"], "note:1")
- empty = source.database.create_shard_log_response(shard_id="missing")
- self.assertEqual(empty["shardEpoch"], 0)
- self.assertEqual(empty["entries"], [])
- self.assertTrue(empty["resyncRequired"])
- self.assertEqual(empty["reason"], "unknown_shard")
- stale = source.database.create_shard_log_response(shard_id="notes", shard_epoch=2)
- self.assertEqual(stale["shardEpoch"], 3)
- self.assertEqual(stale["entries"], [])
- self.assertTrue(stale["resyncRequired"])
- self.assertEqual(stale["reason"], "stale_epoch")
- with self.assertRaises(ValueError):
- source.database.create_shard_log_response(shard_id="notes", after_sequence=-1)
- with self.assertRaises(ValueError):
- source.database.create_shard_log_response(shard_id="notes", limit=-1)
-
- target = CultMesh.create_node(runtime_id="mesh-log-target")
- target.database.register_document(document)
- target.database.put(document, "note:1", {"body": "first"})
- applied = target.database.apply_shard_log_response(response)
- self.assertEqual([record.change_kind for record in applied], ["updated", "removed"])
- self.assertIsNone(target.database.get(document, "note:1"))
- self.assertIsNone(target.database.get(document, "note:2"))
-
- typed_target = CultMesh.create_node(runtime_id="mesh-log-typed-target")
- typed_target.database.register_document(document)
- typed_target.database.put(document, "note:1", {"body": "first"})
- typed_applied = typed_target.database.apply_shard_log_response(typed_response)
- self.assertEqual([record.change_kind for record in typed_applied], ["updated", "removed"])
- self.assertIsNone(typed_target.database.get(document, "note:1"))
-
- def test_cultnet_file_shard_mutation_log_store_persists_authoritative_entries(self) -> None:
- document = define_database_entry_type(
- "mesh.durable_log_note",
- [("body", 0)],
- schema_id="mesh.durable_log_note.v1",
- )
- with tempfile.TemporaryDirectory() as tmp:
- store: CultNetShardMutationLogStore = CultNetFileShardMutationLogStore(Path(tmp) / "logs")
- source = CultMesh.create_node(runtime_id="durable-primary")
- source.database.register_document(document)
- source.database.use_shard_mutation_log_store(store)
- source.database.put_raw_message(document, "note:1", {"body": "first"}, shard_id="durable", shard_epoch=4)
- source.database.put_raw_message(document, "note:2", {"body": "second"}, shard_id="durable", shard_epoch=4)
-
- reopened = CultMesh.create_node(runtime_id="durable-primary")
- reopened.database.register_document(document)
- reopened.database.use_shard_mutation_log_store(CultNetFileShardMutationLogStore(Path(tmp) / "logs"))
- response = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4)
- self.assertEqual(reopened.database.shard_ids(), ["durable"])
- self.assertEqual(reopened.database.shard_schema_ids("durable"), ["mesh.durable_log_note.v1"])
- self.assertEqual([entry.sequence for entry in response.entries], [1, 2])
- self.assertEqual(response.entries[0].raw_document.record_key, "note:1")
-
- store.compact_through("durable", 1)
- compacted = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=0)
- retained = reopened.database.build_shard_log_response(shard_id="durable", shard_epoch=4, after_sequence=1)
- self.assertTrue(compacted.resync_required)
- self.assertEqual(compacted.reason, "compacted_history")
- self.assertEqual(compacted.compacted_through, 1)
- self.assertEqual([entry.sequence for entry in retained.entries], [2])
-
- def test_cultnet_shard_replicator_pulls_logs_and_resyncs_from_snapshot(self) -> None:
- document = define_database_entry_type(
- "mesh.replica_note",
- [("body", 0)],
- schema_id="mesh.replica_note.v1",
- )
- source = CultMesh.create_node(runtime_id="primary")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="notes", shard_epoch=3)
- source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="notes", shard_epoch=3)
- with tempfile.TemporaryDirectory() as tmp:
- cursor_path = Path(tmp) / "replica-cursors.msgpack"
- server = CultMesh.serve_node(source)
- try:
- shard = CultNetShardDescriptor(
- shard_id="notes",
- owner_runtime_id="primary",
- epoch=3,
- is_primary=False,
- schema_ids=("mesh.replica_note.v1",),
- primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- )
- target = CultMesh.create_node(runtime_id="replica")
- target.database.register_document(document)
- cursor_store = CultNetFileShardReplicaCursorStore(cursor_path)
- replicator = CultNetShardReplicator(
- target.database,
- CultNetShardReplicatorOptions(
- fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0),
- snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0),
- cursor_store=cursor_store,
- batch_size=16,
- ),
- )
-
- first_sequence = replicator.pull_once(shard)
- restarted_replicator = CultNetShardReplicator(
- target.database,
- CultNetShardReplicatorOptions(
- fetcher=CultNetSchemaShardLogFetcher(timeout_seconds=2.0),
- snapshot_fetcher=CultNetSchemaShardSnapshotFetcher(timeout_seconds=2.0),
- cursor_store=CultNetFileShardReplicaCursorStore(cursor_path),
- batch_size=16,
- ),
- )
- second_sequence = restarted_replicator.pull_once(shard)
-
- self.assertEqual(first_sequence, 2)
- self.assertEqual(second_sequence, 2)
- self.assertEqual(target.get_required(document, "note:1")["body"], "one")
- self.assertEqual(target.get_required(document, "note:2")["body"], "two")
- cursor = CultNetFileShardReplicaCursorStore(cursor_path).read("notes")
- self.assertIsNotNone(cursor)
- assert cursor is not None
- self.assertEqual(cursor.last_applied_sequence, 2)
- self.assertEqual(cursor.shard_epoch, 3)
- finally:
- server.stop()
-
- resync_target = CultMesh.create_node(runtime_id="resync-replica")
- resync_target.database.register_document(document)
- snapshot = source.database.build_snapshot_response(shard_id="notes", shard_epoch=3, shard_log_sequence=2)
-
- class CompactingFetcher:
- def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse:
- return CultNetShardLogResponse(
- message_id="compact",
- shard_id=shard.shard_id,
- shard_epoch=shard.epoch,
- entries=(),
- resync_required=True,
- reason="compacted_history",
- compacted_through=after_sequence,
- )
-
- class SnapshotFetcher:
- def fetch(self, shard: CultNetShardDescriptor) -> CultNetRawSnapshotResponse:
- return snapshot
-
- resync_replicator = CultNetShardReplicator(
- resync_target.database,
- CultNetShardReplicatorOptions(
- fetcher=CompactingFetcher(),
- snapshot_fetcher=SnapshotFetcher(),
- cursor_store=CultNetInMemoryShardReplicaCursorStore(),
- ),
- )
- resync_sequence = resync_replicator.pull_once(CultNetShardDescriptor(
- shard_id="notes",
- owner_runtime_id="primary",
- epoch=3,
- is_primary=False,
- schema_ids=("mesh.replica_note.v1",),
- primary_endpoints=("cultnet://primary:3075",),
- ))
-
- self.assertEqual(resync_sequence, 2)
- self.assertEqual(resync_target.get_required(document, "note:1")["body"], "one")
-
- def test_cultnet_shard_replicator_background_loop_polls_non_primary_shards(self) -> None:
- document = define_database_entry_type(
- "mesh.background_replica_note",
- [("body", 0)],
- schema_id="mesh.background_replica_note.v1",
- )
- source = CultMesh.create_node(runtime_id="background-primary")
- source.database.register_document(document)
- target = CultMesh.create_node(runtime_id="background-replica")
- target.database.register_document(document)
- cursor_store = CultNetInMemoryShardReplicaCursorStore()
- errors: list[Exception] = []
-
- class FlakySourceFetcher:
- def __init__(self) -> None:
- self.calls = 0
-
- def fetch(self, shard: CultNetShardDescriptor, *, after_sequence: int, limit: int | None = None) -> CultNetShardLogResponse:
- self.calls += 1
- if self.calls == 1:
- raise RuntimeError("transient fetch failure")
- return source.database.build_shard_log_response(
- shard_id=shard.shard_id,
- shard_epoch=shard.epoch,
- after_sequence=after_sequence,
- limit=limit,
- )
-
- fetcher = FlakySourceFetcher()
- shard = CultNetShardDescriptor(
- shard_id="background",
- owner_runtime_id="background-primary",
- epoch=7,
- is_primary=False,
- schema_ids=("mesh.background_replica_note.v1",),
- primary_endpoints=("cultnet://127.0.0.1:3075",),
- )
- replicator = CultNetShardReplicator(
- target.database,
- CultNetShardReplicatorOptions(
- fetcher=fetcher,
- cursor_store=cursor_store,
- batch_size=1,
- poll_interval_seconds=0.01,
- on_error=errors.append,
- ),
- )
- try:
- source.database.put_raw_message(document, "note:1", {"body": "one"}, shard_id="background", shard_epoch=7)
- replicator.start([shard])
- self._wait_until(lambda: target.database.get(document, "note:1") is not None)
- source.database.put_raw_message(document, "note:2", {"body": "two"}, shard_id="background", shard_epoch=7)
- self._wait_until(lambda: target.database.get(document, "note:2") is not None)
- finally:
- replicator.stop()
-
- cursor = cursor_store.read("background")
- self.assertEqual(target.get_required(document, "note:1")["body"], "one")
- self.assertEqual(target.get_required(document, "note:2")["body"], "two")
- self.assertIsNotNone(cursor)
- assert cursor is not None
- self.assertEqual(cursor.last_applied_sequence, 2)
- self.assertEqual(cursor.shard_epoch, 7)
- self.assertEqual(len(errors), 1)
- self.assertEqual(str(errors[0]), "transient fetch failure")
-
- def test_cultnet_schema_write_forwarder_sends_raw_writes_to_primary(self) -> None:
- document = define_database_entry_type(
- "mesh.forward_note",
- [("body", 0)],
- schema_id="mesh.forward_note.v1",
- )
- primary = CultMesh.create_node(runtime_id="forward-primary")
- primary.database.register_document(document)
- server = CultMesh.serve_node(primary)
- try:
- shard = CultNetShardDescriptor(
- shard_id="forward",
- owner_runtime_id="forward-primary",
- epoch=9,
- is_primary=False,
- schema_ids=("mesh.forward_note.v1",),
- primary_endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- )
- forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0)
- put = document_put_raw(
- message_id="forward-put",
- key="note:forward",
- schema_id=document.catalog_entry().schema_id,
- stored_at="2026-06-14T00:00:00Z",
- payload=document.encode_payload({"body": "forwarded"}),
- ).to_wire()
- delete = document_delete(
- message_id="forward-delete",
- schema_id=document.catalog_entry().schema_id,
- record_key="note:forward",
- ).to_wire()
-
- forwarder.forward_put(shard, put)
- self._eventually(lambda: primary.database.get(document, "note:forward") is not None)
- self.assertEqual(primary.database.get_required(document, "note:forward")["body"], "forwarded")
- forwarder.forward_delete(shard, delete)
- self._eventually(lambda: primary.database.get(document, "note:forward") is None)
-
- log = primary.database.build_shard_log_response(shard_id="forward", shard_epoch=9)
- self.assertIsNone(primary.database.get(document, "note:forward"))
- self.assertEqual([entry.change_kind for entry in log.entries], ["added", "removed"])
- self.assertEqual(log.entries[0].put["shardId"], "forward")
- self.assertEqual(log.entries[1].delete["shardEpoch"], 9)
- finally:
- server.stop()
-
- def _eventually(self, predicate: Callable[[], bool], *, timeout_seconds: float = 2.0) -> None:
- deadline = time.monotonic() + timeout_seconds
- while time.monotonic() < deadline:
- if predicate():
- return
- time.sleep(0.01)
- self.assertTrue(predicate())
-
- def test_cultmesh_database_watchers_observe_name_and_index_changes(self) -> None:
- document = define_database_entry_type(
- "mesh.named_watch",
- [("name", 0), ("kind", 1)],
- name="name",
- indexes={"kind": "kind"},
- )
- node = CultMesh.create_node(runtime_id="mesh-named-watch")
- node.database.register_document(document)
- name_changes: list[CultMeshDatabaseChange] = []
- index_changes: list[CultMeshDatabaseChange] = []
-
- node.database.watch_by_name(document, "Potion", name_changes.append)
- node.database.watch_by_index(document, "kind", "consumable", index_changes.append)
-
- node.database.put(document, "item:1", {"name": "Potion", "kind": "consumable"})
- node.database.put(document, "item:2", {"name": "Sword", "kind": "weapon"})
- node.database.put(document, "item:1", {"name": "Elixir", "kind": "rare"})
- node.database.delete(document, "item:1")
-
- self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"])
- self.assertEqual([change.record_key for change in name_changes], ["item:1", "item:1"])
- self.assertEqual(name_changes[1].previous_value, {"name": "Potion", "kind": "consumable"})
- self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"])
- self.assertEqual([change.record_key for change in index_changes], ["item:1", "item:1"])
- self.assertEqual(index_changes[1].value, {"name": "Elixir", "kind": "rare"})
-
- def test_cultmesh_database_collection_reads_resolve_same_schema_aliases(self) -> None:
- @dataclass
- class CanonicalItem:
- name: str
- kind: str
-
- @dataclass
- class UiItem:
- name: str
- kind: str
-
- document = define_database_entry_type(
- "mesh.alias_collection_item",
- [("name", 0), ("kind", 1)],
- cls=CanonicalItem,
- name="name",
- indexes={"kind": "kind"},
- schema_id="mesh.alias_collection_item.v1",
- schema_name="mesh.alias_collection_item",
- schema_version="mesh.alias_collection_item.v1",
- )
- alias = define_database_entry_type(
- "mesh.alias_collection_item.ui",
- [("name", 0), ("kind", 1)],
- cls=UiItem,
- name="name",
- indexes={"kind": "kind"},
- schema_id="mesh.alias_collection_item.v1",
- schema_name="mesh.alias_collection_item",
- schema_version="mesh.alias_collection_item.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-alias-collection")
- node.database.register_document(document)
-
- node.database.put(alias, "item:potion", UiItem("Potion", "consumable"))
- node.database.put(document, "item:sword", CanonicalItem("Sword", "weapon"))
-
- all_items = node.database.get_all(alias)
- potion = node.database.get_by_name(alias, "Potion")
- sword = node.get_by_index(alias, "kind", "weapon")
-
- self.assertEqual([item.name for item in all_items], ["Potion", "Sword"])
- self.assertTrue(all(isinstance(item, UiItem) for item in all_items))
- self.assertIsInstance(potion, UiItem)
- self.assertEqual(potion.kind, "consumable")
- self.assertIsInstance(sword, UiItem)
- self.assertEqual(sword.name, "Sword")
- self.assertIsNone(node.database.get_by_name(alias, "Missing"))
-
- def test_cultmesh_database_alias_collection_watches_emit_alias_values(self) -> None:
- @dataclass
- class CanonicalItem:
- name: str
- kind: str
-
- @dataclass
- class UiItem:
- name: str
- kind: str
-
- document = define_database_entry_type(
- "mesh.alias_collection_watch_item",
- [("name", 0), ("kind", 1)],
- cls=CanonicalItem,
- name="name",
- indexes={"kind": "kind"},
- schema_id="mesh.alias_collection_watch_item.v1",
- schema_name="mesh.alias_collection_watch_item",
- schema_version="mesh.alias_collection_watch_item.v1",
- )
- alias = define_database_entry_type(
- "mesh.alias_collection_watch_item.ui",
- [("name", 0), ("kind", 1)],
- cls=UiItem,
- name="name",
- indexes={"kind": "kind"},
- schema_id="mesh.alias_collection_watch_item.v1",
- schema_name="mesh.alias_collection_watch_item",
- schema_version="mesh.alias_collection_watch_item.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-alias-collection-watch")
- node.database.register_document(document)
- name_changes: list[CultMeshDatabaseChange] = []
- index_changes: list[CultMeshDatabaseChange] = []
-
- node.database.watch_by_name(alias, "Potion", name_changes.append)
- node.database.watch_by_index(alias, "kind", "consumable", index_changes.append)
-
- node.database.put(document, "item:potion", CanonicalItem("Potion", "consumable"))
- node.database.put(document, "item:potion", CanonicalItem("Elixir", "rare"))
-
- self.assertEqual([change.change_kind for change in name_changes], ["added", "updated"])
- self.assertTrue(all(change.document.type == alias.type for change in name_changes))
- self.assertIsInstance(name_changes[0].value, UiItem)
- self.assertIsInstance(name_changes[1].value, UiItem)
- self.assertIsInstance(name_changes[1].previous_value, UiItem)
- self.assertEqual(name_changes[1].previous_value.name, "Potion")
- self.assertEqual(name_changes[1].value.name, "Elixir")
- self.assertEqual([change.change_kind for change in index_changes], ["added", "updated"])
- self.assertTrue(all(change.document.type == alias.type for change in index_changes))
- self.assertIsInstance(index_changes[0].value, UiItem)
- self.assertIsInstance(index_changes[1].previous_value, UiItem)
-
- def test_cultmesh_database_watch_by_name_and_index_validate_lookup_shape(self) -> None:
- document = define_database_entry_type("mesh.unnamed_watch", [("body", 0)])
- node = CultMesh.create_node(runtime_id="mesh-watch-validation")
- node.database.register_document(document)
-
- with self.assertRaises(ValueError):
- node.database.watch_by_name(document, "missing", lambda _: None)
- with self.assertRaises(ValueError):
- node.database.watch_by_index(document, "missing", "value", lambda _: None)
- with self.assertRaises(ValueError):
- node.database.watch_by_index(document, "", "value", lambda _: None)
- with self.assertRaises(ValueError):
- node.database.watch_by_index(document, "missing", "", lambda _: None)
-
- def test_cultmesh_facade_matches_peer_runtime_entrypoints(self) -> None:
- with tempfile.TemporaryDirectory() as tmp:
- store_path = Path(tmp) / "mesh.cc"
- node = CultMesh.start_node(store_path, runtime_id="mesh-facade")
-
- self.assertEqual(node.runtime_id, "mesh-facade")
- self.assertIsInstance(CultMesh.create_node(), type(node))
- self.assertIsInstance(CultMesh.create_verse_catalog(), CultMeshVerseCatalog)
- self.assertIsInstance(CultMesh.create_peer_catalog(), CultMeshPeerCatalog)
- self.assertIsInstance(CultMesh.create_authority_lease_catalog(), CultMeshAuthorityLeaseCatalog)
- self.assertIsInstance(CultMesh.create_stream_catalog(), CultMeshStreamCatalog)
- self.assertIsInstance(CultMesh.create_schema_catalog(), CultNetSchemaCatalog)
- self.assertIsInstance(CultMesh.create_builtin_schema_catalog(), CultNetSchemaCatalog)
- self.assertIsInstance(CultMesh.create_shard_catalog(), CultNetShardCatalog)
- builtins = CultMesh.create_builtin_schema_catalog(
- include_schema_json=False,
- kinds=["shared_contract"],
- )
- self.assertEqual(
- builtins.list()[0].schema_id,
- "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json",
- )
- self.assertIsNone(builtins.list()[0].schema_json)
- peer_exchange_schema_id = (
- "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json"
- )
- filtered_builtin = CultMesh.create_builtin_schema_catalog(
- include_schema_json=True,
- schema_ids=[peer_exchange_schema_id],
- )
- self.assertEqual(
- filtered_builtin.list()[0].schema_version,
- "cultmesh.peer_exchange_request.v0",
- )
- self.assertIn(
- "cultmesh.peer_exchange_request.v0",
- filtered_builtin.list()[0].schema_json or "",
- )
- shard_catalog = CultMesh.create_shard_catalog()
- shard_catalog.upsert(
- CultNetShardDescriptor(
- shard_id="python-notes-a",
- owner_runtime_id="mesh-facade",
- epoch=7,
- is_primary=True,
- schema_ids=("python.note.v1",),
- key_prefix="note:",
- )
- )
- self.assertEqual(
- [
- shard.shard_id
- for shard in shard_catalog.list(schema_ids=["python.note.v1"], record_keys=["note:1"])
- ],
- ["python-notes-a"],
- )
- verse_client = CultMesh.create_verse_discovery_client("127.0.0.1", 4010, timeout_seconds=1.5)
- peer_client = CultMesh.create_peer_exchange_client("127.0.0.1", 4011, timeout_seconds=1.5)
- raw_client = CultMesh.create_client("127.0.0.1", 4012, timeout_seconds=1.5)
- connected_client = CultMesh.connect_client("127.0.0.1", 4013, timeout_seconds=1.5)
- self.assertIsInstance(verse_client, CultMeshVerseDiscoveryClient)
- self.assertIsInstance(peer_client, CultMeshPeerExchangeClient)
- self.assertIsInstance(verse_client, CultMeshDiscoveryClient)
- self.assertIsInstance(peer_client, CultMeshDiscoveryClient)
- self.assertIsInstance(raw_client, CultNetRawClient)
- self.assertIsInstance(connected_client, CultNetRawClient)
- self.assertEqual((verse_client.host, verse_client.port, verse_client.timeout_seconds), ("127.0.0.1", 4010, 1.5))
- self.assertEqual((peer_client.host, peer_client.port, peer_client.timeout_seconds), ("127.0.0.1", 4011, 1.5))
- self.assertEqual((raw_client.host, raw_client.port, raw_client.timeout_seconds), ("127.0.0.1", 4012, 1.5))
- self.assertEqual((connected_client.host, connected_client.port, connected_client.timeout_seconds), ("127.0.0.1", 4013, 1.5))
-
- def test_cultmesh_simulation_fact_uses_csharp_slot_contract(self) -> None:
- import msgpack # type: ignore
-
- candidate = {
- "shardId": "arena",
- "shardEpoch": 4,
- "frame": 100,
- "subjectId": "bob",
- "claimKind": "hit",
- "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
- "claimSummary": "alice shot bob first",
- "witnessCount": 2,
- "supportWeight": 2.0,
- "totalWeight": 2.0,
- "confidence": 1.0,
- "hasQuorum": True,
- }
-
- fact = CultMeshSimulationFact.from_candidate(candidate, committed_at="2026-06-13T00:00:00Z")
- payload = simulation_fact_document.encode_payload(fact)
- slots = msgpack.unpackb(payload, raw=False)
-
- self.assertEqual(CultMeshSimulationFact.create_record_key(candidate), f"simulation:{fact.fact_id}")
- self.assertEqual(slots[0], fact.fact_id)
- self.assertEqual(slots[1], "arena")
- self.assertEqual(slots[2], 4)
- self.assertEqual(slots[3], 100)
- self.assertEqual(slots[4], "bob")
- self.assertEqual(slots[5], "hit")
- self.assertEqual(slots[6], candidate["claimHash"])
- self.assertEqual(slots[7], "alice shot bob first")
- self.assertEqual(slots[8], 2)
- self.assertEqual(slots[9], 2.0)
- self.assertEqual(slots[10], 2.0)
- self.assertEqual(slots[11], 1.0)
- self.assertEqual(slots[12], "2026-06-13T00:00:00Z")
-
- def test_cultmesh_simulation_fact_committer_rejects_without_quorum_and_stores_fact(self) -> None:
- candidate = {
- "shardId": "arena",
- "shardEpoch": 4,
- "frame": 100,
- "subjectId": "bob",
- "claimKind": "hit",
- "claimHash": compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
- "claimSummary": "alice shot bob first",
- "witnessCount": 2,
- "supportWeight": 2.0,
- "totalWeight": 2.0,
- "confidence": 1.0,
- "hasQuorum": True,
- }
-
- with tempfile.TemporaryDirectory() as tmp:
- node = CultMesh.create_node(Path(tmp) / "facts.cc", runtime_id="mesh-facts")
- committer = CultMesh.create_simulation_fact_committer(node)
-
- rejected = dict(candidate)
- rejected["hasQuorum"] = False
- with self.assertRaisesRegex(ValueError, "before quorum"):
- committer.commit(rejected)
-
- committed = committer.commit(candidate, committed_at="2026-06-13T00:00:00Z")
- stored = node.get_required(simulation_fact_document, committed.key)
-
- self.assertEqual(stored.claim_hash, candidate["claimHash"])
- self.assertEqual(stored.committed_at, "2026-06-13T00:00:00Z")
- self.assertEqual(committed.fact.fact_id, stored.fact_id)
-
- def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None:
- from cultnet_py import CultNetSimulationConsensus
-
- consensus = CultNetSimulationConsensus(
- CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
- )
- claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
- observations = [
- {
- "witnessRuntimeId": "watcher-1",
- "shardId": "arena",
- "shardEpoch": 4,
- "frame": 100,
- "subjectId": "bob",
- "claimKind": "hit",
- "claimHash": claim_hash,
- "claimSummary": "alice shot bob first",
- "weight": 1.0,
- },
- {
- "witnessRuntimeId": "watcher-1",
- "shardId": "arena",
- "shardEpoch": 4,
- "frame": 100,
- "subjectId": "bob",
- "claimKind": "hit",
- "claimHash": "stale-duplicate",
- "weight": 0.5,
- },
- {
- "witnessRuntimeId": "watcher-2",
- "shardId": "arena",
- "shardEpoch": 4,
- "frame": 100,
- "subjectId": "bob",
- "claimKind": "hit",
- "claimHash": claim_hash,
- "weight": 1.0,
- },
- ]
-
- candidates = consensus.build_candidates(observations)
- typed_candidates = consensus.build_candidate_objects([
- CultNetSimulationObservation.from_wire(observation)
- for observation in observations
- ])
-
- self.assertEqual(len(candidates), 1)
- self.assertEqual(candidates[0]["claimHash"], claim_hash)
- self.assertEqual(candidates[0]["witnessCount"], 2)
- self.assertEqual(candidates[0]["supportWeight"], 2.0)
- self.assertTrue(candidates[0]["hasQuorum"])
- self.assertEqual(len(typed_candidates), 1)
- self.assertIsInstance(typed_candidates[0], CultNetSimulationConsensusCandidate)
- self.assertEqual(typed_candidates[0].claim_hash, claim_hash)
- self.assertEqual(typed_candidates[0].to_wire(), candidates[0])
- self.assertEqual(CultNetSimulationConsensusCandidate.from_wire(candidates[0]), typed_candidates[0])
-
- def test_cultmesh_game_session_submits_observations_and_commits_quorum_once(self) -> None:
- claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
- first = simulation_observation(
- message_id="obs-1",
- witness_runtime_id="watcher-1",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ).to_wire()
- second = simulation_observation(
- message_id="obs-2",
- witness_runtime_id="watcher-2",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ).to_wire()
-
- with tempfile.TemporaryDirectory() as tmp:
- node = CultMesh.create_node(Path(tmp) / "session.cc", runtime_id="mesh-session")
- session = CultMesh.create_game_session(
- node,
- CultMeshGameSessionOptions(
- consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
- ),
- )
- seen_candidates: list[CultNetSimulationConsensusCandidate] = []
- seen_facts: list[CultMeshDatabaseChange] = []
- unsubscribe_candidates = session.watch_candidates(seen_candidates.append)
- unsubscribe_facts = session.watch_simulation_facts(seen_facts.append)
-
- self.assertEqual(session.submit_and_commit(CultNetSimulationObservation.from_wire(first)), [])
- typed_candidates = session.submit_observation_candidates(CultNetSimulationObservation.from_wire(second))
- commits = session.commit_quorum_candidate_objects(typed_candidates)
- replay = session.commit_quorum_candidates(session.submit_observation(second))
- unsubscribe_candidates()
- unsubscribe_facts()
-
- self.assertEqual(len(typed_candidates), 1)
- self.assertTrue(typed_candidates[0].has_quorum)
- self.assertEqual(len(commits), 1)
- self.assertEqual(replay, [])
- self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash, claim_hash])
- self.assertEqual(len(seen_facts), 1)
- self.assertEqual(seen_facts[0].change_kind, "added")
- self.assertEqual(seen_facts[0].value.claim_hash, claim_hash)
- stored = node.get_required(simulation_fact_document, commits[0].key)
- self.assertEqual(stored.claim_hash, claim_hash)
- self.assertEqual(stored.witness_count, 2)
-
- def test_cultmesh_game_session_serves_catalogs_and_observations_over_wire(self) -> None:
- verse_catalog = CultMeshVerseCatalog()
- peer_catalog = CultMeshPeerCatalog()
- verse_catalog.upsert(CultMeshVerseDescriptor(
- verse_id="session-verse",
- display_name="Session Verse",
- authority_model="local",
- compatibility=CultMeshVerseCompatibility("cultmesh.v0", "session-rules"),
- ))
- peer_catalog.upsert(CultMeshPeerCard(
- peer_id="session-peer",
- verse_id="session-verse",
- endpoints=("cultnet://127.0.0.1:0",),
- roles=("simulation-observer", "read-replica"),
- shard_ids=("arena",),
- ))
- session = CultMesh.create_game_session(
- CultMesh.create_node(runtime_id="session-peer"),
- CultMeshGameSessionOptions(
- verse_catalog=verse_catalog,
- peer_catalog=peer_catalog,
- consensus_options=CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0),
- ),
- )
- server = session.serve(display_name="Session Peer")
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- hello_response = raw_client.request(
- hello(runtime_id="session-prober"),
- expected_schema_version="cultnet.hello.v0",
- )
- discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
- verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
- peers = discovery_client.fetch_peers(verse_id="session-verse", roles=["simulation-observer"])
- claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
- first = raw_client.request(
- simulation_observation(
- message_id="session-wire-1",
- witness_runtime_id="watcher-1",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ),
- expected_schema_version="cultnet.simulation_consensus_candidate.v0",
- )
- second = raw_client.request(
- simulation_observation(
- message_id="session-wire-2",
- witness_runtime_id="watcher-2",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ),
- expected_schema_version="cultnet.simulation_consensus_candidate.v0",
- )
- finally:
- server.stop()
-
- self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
- self.assertEqual(verses[0].verse_id, "session-verse")
- self.assertEqual(peers[0].peer_id, "session-peer")
- self.assertIn("simulation-observer", peers[0].roles)
- self.assertFalse(first["hasQuorum"])
- self.assertEqual(first["witnessCount"], 1)
- self.assertTrue(second["hasQuorum"])
- self.assertEqual(second["witnessCount"], 2)
-
- def test_cultmesh_game_session_can_disable_served_simulation_observations(self) -> None:
- verse_catalog = CultMeshVerseCatalog()
- verse_catalog.upsert(CultMeshVerseDescriptor(
- verse_id="session-quiet",
- display_name="Quiet Session",
- authority_model="local",
- compatibility=CultMeshVerseCompatibility("cultmesh.v0", "quiet-rules"),
- ))
- session = CultMesh.create_game_session(
- CultMesh.create_node(runtime_id="quiet-session-peer"),
- CultMeshGameSessionOptions(
- verse_catalog=verse_catalog,
- serve_simulation_observations=False,
- ),
- )
- server = session.serve(display_name="Quiet Session Peer")
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- hello_response = raw_client.request(
- hello(runtime_id="quiet-session-prober"),
- expected_schema_version="cultnet.hello.v0",
- )
- discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
- verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
- with self.assertRaisesRegex(CultNetPeerError, "Simulation observations are not enabled") as raised:
- raw_client.request(
- simulation_observation(
- message_id="quiet-session-sim",
- witness_runtime_id="watcher-1",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=compute_simulation_claim_hash("hit", "alice", "bob", "frame:100"),
- ),
- expected_schema_version="cultnet.simulation_consensus_candidate.v0",
- )
- finally:
- server.stop()
-
- self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
- self.assertEqual(verses[0].verse_id, "session-quiet")
- self.assertEqual(raised.exception.response["code"], "simulation_observations_disabled")
-
- def test_cultmesh_game_session_prediction_requires_scope_and_reconciles_shard_log(self) -> None:
- note_doc = define_database_entry_type(
- "mesh.input",
- [("body", 0)],
- schema_id="mesh.input.v1",
- )
- with tempfile.TemporaryDirectory() as tmp:
- node = CultMesh.create_node(Path(tmp) / "prediction.cc", runtime_id="client-a")
- node.register_document(note_doc)
- session = CultMesh.create_game_session(
- node,
- CultMeshGameSessionOptions(
- client_authority_scopes=(
- CultNetClientAuthorityScope(
- "client-a",
- schema_ids=("mesh.input.v1",),
- key_prefix="input:client-a",
- ),
- )
- ),
- )
-
- with self.assertRaisesRegex(ValueError, "does not have client prediction authority"):
- session.predict(note_doc, "input:other", {"body": "nope"})
-
- prediction = session.predict(note_doc, "input:client-a:move", {"body": "predicted"})
- put = document_put_raw(
- message_id="authoritative",
- key=prediction.key,
- schema_id=prediction.schema_id,
- stored_at="2026-06-13T00:00:00Z",
- payload=note_doc.encode_payload({"body": "authoritative"}),
- shard_id="inputs",
- shard_epoch=1,
- )
- authoritative_response = {
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "inputs-log",
- "shardId": "inputs",
- "shardEpoch": 1,
- "entries": [
- {
- "sequence": 1,
- "committedAt": "2026-06-13T00:00:00Z",
- "changeKind": "updated",
- "put": put.to_wire(),
- }
- ],
- "resyncRequired": False,
- }
- changes = session.apply_shard_log_response(CultNetShardLogResponse.from_wire(authoritative_response))
-
- self.assertEqual(changes[0].change_kind, "reconciled")
- self.assertEqual(node.get_required(note_doc, prediction.key)["body"], "authoritative")
- self.assertEqual(session.pending_predictions(), ())
- self.assertEqual(session.resimulation_inputs(), ())
-
- def test_cultmesh_game_session_partial_reconciliation_keeps_unmatched_predictions(self) -> None:
- note_doc = define_database_entry_type(
- "mesh.partial_input",
- [("body", 0)],
- schema_id="mesh.partial_input.v1",
- )
- node = CultMesh.create_node(runtime_id="client-partial")
- node.register_document(note_doc)
- session = CultMesh.create_game_session(
- node,
- CultMeshGameSessionOptions(
- client_authority_scopes=(
- CultNetClientAuthorityScope(
- "client-partial",
- schema_ids=("mesh.partial_input.v1",),
- key_prefix="input:client-partial",
- ),
- )
- ),
- )
- pending = session.predict(note_doc, "input:client-partial:pending", {"body": "pending-local"})
- accepted = session.predict(note_doc, "input:client-partial:accepted", {"body": "accepted-local"})
- put = document_put_raw(
- message_id="authoritative-partial",
- key=accepted.key,
- schema_id=accepted.schema_id,
- stored_at="2026-06-13T00:00:00Z",
- payload=note_doc.encode_payload({"body": "accepted-authority"}),
- shard_id="inputs",
- shard_epoch=1,
- )
-
- changes = session.apply_shard_log_response({
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "inputs-partial-log",
- "shardId": "inputs",
- "shardEpoch": 1,
- "entries": [
- {
- "sequence": 1,
- "committedAt": "2026-06-13T00:00:00Z",
- "changeKind": "updated",
- "put": put.to_wire(),
- }
- ],
- "resyncRequired": False,
- })
-
- self.assertEqual([(change.change_kind, change.record_key) for change in changes], [
- ("reconciled", accepted.key),
- ])
- self.assertEqual(node.get_required(note_doc, accepted.key)["body"], "accepted-authority")
- self.assertEqual(node.get_required(note_doc, pending.key)["body"], "pending-local")
- self.assertEqual([prediction.key for prediction in session.pending_predictions()], [pending.key])
- self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [pending.key])
-
- def test_cultmesh_game_session_rolls_back_pending_predictions(self) -> None:
- note_doc = define_database_entry_type(
- "mesh.rollback_input",
- [("body", 0)],
- schema_id="mesh.rollback_input.v1",
- )
- node = CultMesh.create_node(runtime_id="client-rollback")
- node.register_document(note_doc)
- node.put(note_doc, "input:client-rollback:existing", {"body": "previous"})
- session = CultMesh.create_game_session(
- node,
- CultMeshGameSessionOptions(
- client_authority_scopes=(
- CultNetClientAuthorityScope(
- "client-rollback",
- schema_ids=("mesh.rollback_input.v1",),
- key_prefix="input:client-rollback",
- ),
- )
- ),
- )
-
- existing = session.predict(note_doc, "input:client-rollback:existing", {"body": "predicted-existing"})
- created = session.predict(note_doc, "input:client-rollback:new", {"body": "predicted-new"})
- self.assertEqual(
- [prediction.key for prediction in session.pending_predictions()],
- ["input:client-rollback:existing", "input:client-rollback:new"],
- )
- self.assertEqual([prediction.key for prediction in session.resimulation_inputs()], [
- "input:client-rollback:existing",
- "input:client-rollback:new",
- ])
-
- restored = session.rollback_prediction(existing)
- self.assertIsNotNone(restored)
- assert restored is not None
- self.assertEqual(restored.change_kind, "rolled_back")
- self.assertEqual(restored.value, {"body": "previous"})
- self.assertEqual(node.get_required(note_doc, "input:client-rollback:existing")["body"], "previous")
-
- rolled_back = session.rollback_predictions()
- self.assertEqual([(change.change_kind, change.record_key, change.value) for change in rolled_back], [
- ("rolled_back", created.key, None),
- ])
- self.assertIsNone(node.get(note_doc, created.key))
- self.assertEqual(session.pending_predictions(), ())
-
- def test_cultmesh_game_session_authoritative_delete_rolls_back_prediction(self) -> None:
- note_doc = define_database_entry_type(
- "mesh.deleted_input",
- [("body", 0)],
- schema_id="mesh.deleted_input.v1",
- )
- node = CultMesh.create_node(runtime_id="client-delete")
- node.register_document(note_doc)
- session = CultMesh.create_game_session(
- node,
- CultMeshGameSessionOptions(
- client_authority_scopes=(
- CultNetClientAuthorityScope(
- "client-delete",
- schema_ids=("mesh.deleted_input.v1",),
- key_prefix="input:client-delete",
- ),
- )
- ),
- )
-
- prediction = session.predict(note_doc, "input:client-delete:move", {"body": "predicted"})
- delete = document_delete(
- message_id="authoritative-delete",
- schema_id=prediction.schema_id,
- record_key=prediction.key,
- shard_id="inputs",
- shard_epoch=1,
- )
- changes = session.apply_shard_log_response({
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "inputs-delete-log",
- "shardId": "inputs",
- "shardEpoch": 1,
- "entries": [
- {
- "sequence": 1,
- "committedAt": "2026-06-13T00:00:00Z",
- "changeKind": "removed",
- "delete": delete.to_wire(),
- }
- ],
- "resyncRequired": False,
- })
-
- self.assertEqual(changes[0].change_kind, "rolled_back")
- self.assertIsNone(node.get(note_doc, prediction.key))
- self.assertEqual(session.pending_predictions(), ())
-
- def test_cultmesh_database_watchers_observe_authoritative_shard_log_reconciliation(self) -> None:
- note_doc = define_database_entry_type(
- "mesh.watched_input",
- [("body", 0)],
- schema_id="mesh.watched_input.v1",
- )
- node = CultMesh.create_node(runtime_id="client-watch")
- node.database.register_document(note_doc)
- seen: list[CultMeshDatabaseChange] = []
- node.database.watch_record(note_doc, "input:client-watch:move", seen.append)
- session = CultMesh.create_game_session(
- node,
- CultMeshGameSessionOptions(
- client_authority_scopes=(
- CultNetClientAuthorityScope(
- "client-watch",
- schema_ids=("mesh.watched_input.v1",),
- key_prefix="input:client-watch",
- ),
- )
- ),
- )
-
- prediction = session.predict(note_doc, "input:client-watch:move", {"body": "predicted"})
- put = document_put_raw(
- message_id="authoritative-watch",
- key=prediction.key,
- schema_id=prediction.schema_id,
- stored_at="2026-06-13T00:00:00Z",
- payload=note_doc.encode_payload({"body": "authoritative"}),
- shard_id="inputs",
- shard_epoch=1,
- )
- session_changes = session.apply_shard_log_response({
- "schemaVersion": "cultnet.shard_log_response.v0",
- "messageId": "inputs-watch-log",
- "shardId": "inputs",
- "shardEpoch": 1,
- "entries": [
- {
- "sequence": 1,
- "committedAt": "2026-06-13T00:00:00Z",
- "changeKind": "updated",
- "put": put.to_wire(),
- }
- ],
- "resyncRequired": False,
- })
-
- self.assertEqual([change.change_kind for change in seen], ["added", "updated"])
- self.assertEqual(seen[1].value, {"body": "authoritative"})
- self.assertEqual(seen[1].previous_value, {"body": "predicted"})
- self.assertEqual(session_changes[0].change_kind, "reconciled")
-
- def test_cultmesh_verse_catalog_response_matches_schema_v0_wire_shape(self) -> None:
- import msgpack # type: ignore
-
- catalog = CultMeshVerseCatalog()
- catalog.upsert(
- CultMeshVerseDescriptor(
- verse_id="aetheria-main",
- display_name="Aetheria",
- authority_model="OperatorCluster",
- compatibility=CultMeshVerseCompatibility(
- transport_version="cultmesh.v0",
- rules_hash="rules",
- compatible_verse_ids=("aetheria-modded",),
- required_plugin_ids=("core",),
- optional_plugin_ids=("skylands",),
- ),
- discovery_endpoints=("cultmesh://aetheria.example.test:3075",),
- authority_runtime_ids=("runtime-a",),
- description="main branch",
- )
- )
-
- response = catalog.create_response(verse_catalog_request("verses-1"))
- decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False)
- self.assertEqual(decoded["schemaVersion"], "cultmesh.verse_catalog_response.v0")
- self.assertEqual(decoded["messageId"], "verses-1")
- self.assertEqual(decoded["verses"][0]["verseId"], "aetheria-main")
- self.assertEqual(decoded["verses"][0]["compatibility"]["requiredPluginIds"], ["core"])
-
- def test_cultmesh_verse_catalog_watches_updates_and_finds_transfer_targets(self) -> None:
- source = CultMeshVerseDescriptor(
- verse_id="aetheria-main",
- display_name="Aetheria",
- authority_model="federated",
- compatibility=CultMeshVerseCompatibility(
- transport_version="cultmesh.v0",
- rules_hash="rules-main",
- ),
- )
- compatible = CultMeshVerseDescriptor(
- verse_id="aetheria-modded",
- display_name="Aetheria Modded",
- authority_model="federated",
- compatibility=CultMeshVerseCompatibility(
- transport_version="cultmesh.v0",
- rules_hash="rules-modded",
- compatible_verse_ids=("aetheria-main",),
- ),
- )
- incompatible = CultMeshVerseDescriptor(
- verse_id="old-world",
- display_name="Old World",
- authority_model="solo",
- compatibility=CultMeshVerseCompatibility(
- transport_version="cultmesh.v0",
- rules_hash="rules-old",
- ),
- )
- catalog = CultMeshVerseCatalog()
- seen: list[str] = []
- unsubscribe = catalog.watch(lambda verse: seen.append(verse.verse_id))
-
- catalog.upsert(source)
- catalog.apply_response({
- "schemaVersion": "cultmesh.verse_catalog_response.v0",
- "messageId": "verses-watch",
- "verses": [compatible.to_wire(), incompatible.to_wire()],
- })
- unsubscribe()
- catalog.upsert(CultMeshVerseDescriptor(
- verse_id="after-unsubscribe",
- display_name="After",
- authority_model="none",
- compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-after"),
- ))
-
- self.assertEqual(seen, ["aetheria-main", "aetheria-modded", "old-world"])
- self.assertEqual([verse.verse_id for verse in catalog.verses], ["aetheria-main", "aetheria-modded", "after-unsubscribe", "old-world"])
- self.assertEqual(catalog.get("aetheria-modded"), compatible)
- self.assertEqual([verse.verse_id for verse in catalog.find_transfer_targets(source)], ["aetheria-modded"])
-
- def test_cultmesh_peer_exchange_response_matches_schema_v0_wire_shape(self) -> None:
- import msgpack # type: ignore
-
- catalog = CultMeshPeerCatalog()
- catalog.upsert(
- CultMeshPeerCard(
- peer_id="peer-a",
- verse_id="aetheria-main",
- endpoints=("cultnet://peer-a.example.test:3075",),
- roles=("discovery", "read-replica"),
- shard_ids=("players",),
- region="eu-west",
- authority_lease_id="lease-1",
- expires_at="2026-05-20T12:00:00.0000000Z",
- signature="sig",
- )
- )
-
- response = catalog.create_response(
- peer_exchange_request("pex-1", verse_id="aetheria-main", roles=["read-replica"])
- )
- decoded = msgpack.unpackb(msgpack.packb(response, use_bin_type=True), raw=False)
- self.assertEqual(decoded["schemaVersion"], "cultmesh.peer_exchange_response.v0")
- self.assertEqual(decoded["messageId"], "pex-1")
- self.assertEqual(decoded["peers"][0]["peerId"], "peer-a")
- self.assertIn("read-replica", decoded["peers"][0]["roles"])
- self.assertEqual(decoded["peers"][0]["authorityLeaseId"], "lease-1")
-
- def test_cultmesh_peer_catalog_watches_updates_and_gets_peers(self) -> None:
- first = CultMeshPeerCard(
- peer_id="peer-a",
- verse_id="aetheria-main",
- endpoints=("cultnet://peer-a.example.test:3075",),
- roles=("discovery", "read-replica"),
- )
- second = CultMeshPeerCard(
- peer_id="peer-b",
- verse_id="aetheria-main",
- endpoints=("cultnet://peer-b.example.test:3075",),
- roles=("shard-primary",),
- )
- catalog = CultMeshPeerCatalog()
- seen: list[str] = []
- unsubscribe = catalog.watch(lambda peer: seen.append(peer.peer_id))
-
- catalog.upsert(first)
- catalog.apply_response({
- "schemaVersion": "cultmesh.peer_exchange_response.v0",
- "messageId": "peers-watch",
- "peers": [second.to_wire()],
- })
- unsubscribe()
- catalog.upsert(CultMeshPeerCard(
- peer_id="peer-c",
- verse_id="aetheria-main",
- endpoints=("cultnet://peer-c.example.test:3075",),
- roles=("read-replica",),
- ))
-
- self.assertEqual(seen, ["peer-a", "peer-b"])
- self.assertTrue(first.has_role("read-replica"))
- self.assertEqual([peer.peer_id for peer in catalog.peers], ["peer-a", "peer-b", "peer-c"])
- self.assertEqual(catalog.get("peer-b"), second)
- self.assertEqual([peer.peer_id for peer in catalog.find("aetheria-main", role="read-replica")], ["peer-a", "peer-c"])
-
- def test_cultmesh_discovery_client_fetches_typed_catalogs_over_cultnet_frames(self) -> None:
- import msgpack # type: ignore
- from cultnet_py import read_frame, write_frame
-
- verses = CultMeshVerseCatalog()
- verses.upsert(
- CultMeshVerseDescriptor(
- verse_id="aetheria-main",
- display_name="Aetheria",
- authority_model="federated",
- compatibility=CultMeshVerseCompatibility(
- transport_version="cultmesh.v0",
- rules_hash="rules-1",
- required_plugin_ids=("core",),
- ),
- discovery_endpoints=("cultmesh://aetheria.example.test:3075",),
- authority_runtime_ids=("runtime-a",),
- )
- )
- peers = CultMeshPeerCatalog()
- peers.upsert(
- CultMeshPeerCard(
- peer_id="peer-a",
- verse_id="aetheria-main",
- endpoints=("cultnet://peer-a.example.test:3075",),
- roles=("read-replica",),
- shard_ids=("players",),
- )
- )
-
- ready = threading.Event()
- server_error: list[BaseException] = []
-
- def serve_requests() -> None:
- try:
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
- server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- server.bind(("127.0.0.1", 0))
- port_holder.append(server.getsockname()[1])
- server.listen(8)
- ready.set()
- for _ in range(8):
- connection, _ = server.accept()
- with connection:
- stream = connection.makefile("rwb")
- request = msgpack.unpackb(read_frame(stream), raw=False)
- if request["schemaVersion"] == "cultmesh.verse_catalog_request.v0":
- response = verses.create_response(request)
- elif request["schemaVersion"] == "cultmesh.peer_exchange_request.v0":
- response = peers.create_response(request)
- else:
- raise AssertionError(f"unexpected request {request['schemaVersion']}")
- write_frame(stream, msgpack.packb(response, use_bin_type=True))
- stream.flush()
- except BaseException as error:
- server_error.append(error)
- ready.set()
-
- port_holder: list[int] = []
- thread = threading.Thread(target=serve_requests, daemon=True)
- thread.start()
- self.assertTrue(ready.wait(2.0))
- self.assertFalse(server_error)
-
- client = CultMeshDiscoveryClient("127.0.0.1", port_holder[0], timeout_seconds=2.0)
- fetched_verses = client.fetch_verses(transport_version="cultmesh.v0")
- fetched_peers = client.fetch_peers(verse_id="aetheria-main", roles=["read-replica"])
- local_verses = CultMeshVerseCatalog()
- local_peers = CultMeshPeerCatalog()
- synced_verses = client.sync_verse_catalog(local_verses, transport_version="cultmesh.v0")
- synced_peers = client.sync_peer_catalog(local_peers, verse_id="aetheria-main", roles=["read-replica"])
- discovered_verses = CultMeshVerseCatalog()
- discovered_peers = CultMeshPeerCatalog()
- endpoint = f"cultnet://127.0.0.1:{port_holder[0]}"
- verse_discovery = CultMeshVerseDiscoveryClient.from_endpoint(endpoint, timeout_seconds=2.0)
- peer_discovery = CultMeshPeerExchangeClient.from_endpoint(endpoint, timeout_seconds=2.0)
- discovered_verse_count = verse_discovery.discover(
- discovered_verses,
- endpoints=[endpoint, endpoint, ""],
- transport_version="cultmesh.v0",
- )
- discovered_peer_count = peer_discovery.discover(
- discovered_peers,
- verse_id="aetheria-main",
- endpoints=[endpoint, endpoint, ""],
- roles=["read-replica"],
- )
- fanout_verses = CultMeshVerseCatalog()
- fanout_verses.upsert(
- CultMeshVerseDescriptor(
- verse_id="aetheria-main",
- display_name="Known Aetheria",
- authority_model="federated",
- compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules-1"),
- discovery_endpoints=(endpoint, endpoint, ""),
- )
- )
- fanout_peers = CultMeshPeerCatalog()
- fanout_peers.upsert(
- CultMeshPeerCard(
- peer_id="known-peer",
- verse_id="aetheria-main",
- endpoints=(endpoint, endpoint, ""),
- roles=("read-replica",),
- )
- )
- fanout_verse_count = verse_discovery.fanout(fanout_verses, transport_version="cultmesh.v0")
- fanout_peer_count = peer_discovery.fanout(
- fanout_peers,
- verse_id="aetheria-main",
- roles=["read-replica"],
- )
-
- thread.join(2.0)
- self.assertFalse(server_error)
- self.assertEqual(fetched_verses[0].verse_id, "aetheria-main")
- self.assertEqual(fetched_verses[0].compatibility.required_plugin_ids, ("core",))
- self.assertEqual(fetched_peers[0].peer_id, "peer-a")
- self.assertEqual(fetched_peers[0].shard_ids, ("players",))
- self.assertEqual(synced_verses[0].verse_id, "aetheria-main")
- self.assertEqual(local_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a")
- self.assertEqual(synced_peers[0].roles, ("read-replica",))
- self.assertEqual(discovered_verse_count, 1)
- self.assertEqual(discovered_peer_count, 1)
- self.assertEqual(discovered_verses.get("aetheria-main").display_name, "Aetheria")
- self.assertEqual(discovered_peers.find("aetheria-main", role="read-replica")[0].peer_id, "peer-a")
- self.assertEqual(fanout_verse_count, 1)
- self.assertEqual(fanout_peer_count, 1)
- self.assertEqual(fanout_verses.get("aetheria-main").display_name, "Aetheria")
- self.assertIn(
- "peer-a",
- {peer.peer_id for peer in fanout_peers.find("aetheria-main", role="read-replica")},
- )
-
- def test_cultmesh_discovery_client_fans_out_simulation_observations_to_peers(self) -> None:
- first_hub = CultNetSimulationObservationHub(
- CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
- )
- second_hub = CultNetSimulationObservationHub(
- CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
- )
- first_server = CultMesh.serve_node(
- CultMesh.create_node(runtime_id="sim-peer-a"),
- observation_hub=first_hub,
- )
- second_server = CultMesh.serve_node(
- CultMesh.create_node(runtime_id="sim-peer-b"),
- observation_hub=second_hub,
- )
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="sim-peer-a",
- verse_id="arena",
- endpoints=(f"cultnet://127.0.0.1:{first_server.port}",),
- roles=("simulation-witness",),
- ))
- peers.upsert(CultMeshPeerCard(
- peer_id="sim-peer-b",
- verse_id="arena",
- endpoints=(f"cultnet://127.0.0.1:{second_server.port}",),
- roles=("simulation-witness",),
- ))
- claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:200")
- message = simulation_observation(
- message_id="fanout-observation",
- witness_runtime_id="python-fanout",
- shard_id="arena",
- shard_epoch=1,
- frame=200,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice hit bob",
- ).to_wire()
-
- client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0)
- candidates = client.fanout_simulation_observation(
- peers,
- message,
- verse_id="arena",
- roles=["simulation-witness"],
- )
- finally:
- first_server.stop()
- second_server.stop()
-
- self.assertEqual([candidate["schemaVersion"] for candidate in candidates], [
- "cultnet.simulation_consensus_candidate.v0",
- "cultnet.simulation_consensus_candidate.v0",
- ])
- self.assertEqual({candidate["claimHash"] for candidate in candidates}, {claim_hash})
- self.assertEqual({candidate["messageId"] for candidate in candidates}, {"fanout-observation"})
- self.assertEqual({candidate["hasQuorum"] for candidate in candidates}, {True})
-
- def test_cultmesh_simulation_observation_fanout_flushes_queued_messages(self) -> None:
- hub = CultNetSimulationObservationHub(
- CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
- )
- server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-flush-peer"), observation_hub=hub)
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="sim-flush-peer",
- verse_id="arena",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("simulation-witness",),
- ))
- claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:210")
- seen_candidates: list[dict[str, object]] = []
- fanout = CultMeshSimulationObservationFanout(
- CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
- peers,
- verse_id="arena",
- roles=["simulation-witness"],
- on_candidate=seen_candidates.append,
- )
- fanout.enqueue(simulation_observation(
- message_id="queued-observation",
- witness_runtime_id="python-fanout",
- shard_id="arena",
- shard_epoch=1,
- frame=210,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice hit bob",
- ).to_wire())
-
- candidates = fanout.flush()
- finally:
- server.stop()
-
- self.assertEqual(fanout.pending_count(), 0)
- self.assertEqual([candidate["claimHash"] for candidate in candidates], [claim_hash])
- self.assertEqual(seen_candidates[0]["messageId"], "queued-observation")
-
- def test_cultmesh_simulation_observation_fanout_keeps_failed_messages_pending(self) -> None:
- closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- closed_socket.bind(("127.0.0.1", 0))
- closed_port = closed_socket.getsockname()[1]
- closed_socket.close()
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="closed-simulation-peer",
- verse_id="arena",
- endpoints=(f"cultnet://127.0.0.1:{closed_port}",),
- roles=("simulation-witness",),
- ))
- errors: list[tuple[str, str]] = []
- fanout = CultMeshSimulationObservationFanout(
- CultMeshDiscoveryClient("127.0.0.1", closed_port, timeout_seconds=0.2),
- peers,
- verse_id="arena",
- roles=["simulation-witness"],
- on_error=lambda endpoint, error: errors.append((endpoint, str(error))),
- )
- fanout.enqueue(simulation_observation(
- message_id="retry-observation",
- witness_runtime_id="python-fanout",
- shard_id="arena",
- shard_epoch=1,
- frame=212,
- subject_id="bob",
- claim_kind="miss",
- claim_hash=compute_simulation_claim_hash("miss", "alice", "bob", "frame:212"),
- claim_summary="alice missed bob",
- ).to_wire())
-
- candidates = fanout.flush()
-
- self.assertEqual(candidates, [])
- self.assertEqual(fanout.pending_count(), 1)
- self.assertEqual(len(errors), 1)
- self.assertIn(f"127.0.0.1:{closed_port}", errors[0][0])
-
- def test_cultmesh_simulation_observation_fanout_runs_background_loop(self) -> None:
- hub = CultNetSimulationObservationHub(
- CultNetSimulationConsensusOptions(minimum_witnesses=1, quorum_ratio=1.0)
- )
- server = CultMesh.serve_node(CultMesh.create_node(runtime_id="sim-loop-peer"), observation_hub=hub)
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="sim-loop-peer",
- verse_id="arena",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("simulation-witness",),
- ))
- claim_hash = compute_simulation_claim_hash("block", "dana", "eve", "frame:211")
- seen_candidates: list[dict[str, object]] = []
- fanout = CultMeshSimulationObservationFanout(
- CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
- peers,
- verse_id="arena",
- roles=["simulation-witness"],
- interval_seconds=0.05,
- on_candidate=seen_candidates.append,
- )
- fanout.start()
- fanout.enqueue(simulation_observation(
- message_id="loop-observation",
- witness_runtime_id="python-fanout",
- shard_id="arena",
- shard_epoch=1,
- frame=211,
- subject_id="eve",
- claim_kind="block",
- claim_hash=claim_hash,
- claim_summary="dana blocked eve",
- ).to_wire())
- deadline = time.monotonic() + 2.0
- while not seen_candidates and time.monotonic() < deadline:
- time.sleep(0.01)
- fanout.stop()
- finally:
- server.stop()
-
- self.assertEqual(fanout.pending_count(), 0)
- self.assertTrue(seen_candidates)
- self.assertEqual(seen_candidates[0]["claimHash"], claim_hash)
- self.assertEqual(seen_candidates[0]["messageId"], "loop-observation")
-
- def test_cultmesh_discovery_client_fans_out_snapshots_to_peers(self) -> None:
- document = define_database_entry_type(
- "mesh.snapshot_fanout_note",
- [("body", 0)],
- schema_id="mesh.snapshot_fanout_note.v1",
- )
- first = CultMesh.create_node(runtime_id="snapshot-peer-a")
- first.database.register_document(document)
- first.database.put_raw_message(document, "note:a", {"body": "from-a"}, shard_id="notes", shard_epoch=1)
- second = CultMesh.create_node(runtime_id="snapshot-peer-b")
- second.database.register_document(document)
- second.database.put_raw_message(document, "note:b", {"body": "from-b"}, shard_id="notes", shard_epoch=1)
- target = CultMesh.create_node(runtime_id="snapshot-target")
- target.database.register_document(document)
- first_server = CultMesh.serve_node(first)
- second_server = CultMesh.serve_node(second)
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="snapshot-peer-a",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{first_server.port}", f"cultnet://127.0.0.1:{first_server.port}"),
- roles=("read-replica",),
- ))
- peers.upsert(CultMeshPeerCard(
- peer_id="snapshot-peer-b",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{second_server.port}",),
- roles=("read-replica",),
- ))
-
- client = CultMeshDiscoveryClient("127.0.0.1", first_server.port, timeout_seconds=2.0)
- responses = client.fanout_snapshot_responses(
- peers,
- verse_id="mesh",
- roles=["read-replica"],
- schema_ids=["mesh.snapshot_fanout_note.v1"],
- )
- applied = client.sync_snapshots(
- target.database,
- peers,
- verse_id="mesh",
- roles=["read-replica"],
- schema_ids=["mesh.snapshot_fanout_note.v1"],
- )
- finally:
- first_server.stop()
- second_server.stop()
-
- self.assertEqual(len(responses), 2)
- self.assertTrue(all(isinstance(response, CultNetRawSnapshotResponse) for response in responses))
- self.assertEqual(
- sorted(record.record_key for response in responses for record in response.documents),
- ["note:a", "note:b"],
- )
- self.assertEqual(sorted(record.record_key for record in applied), ["note:a", "note:b"])
- self.assertEqual(target.database.get_required(document, "note:a")["body"], "from-a")
- self.assertEqual(target.database.get_required(document, "note:b")["body"], "from-b")
-
- def test_cultmesh_discovery_client_sync_documents_returns_requested_aliases(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.snapshot_fanout_alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.snapshot_fanout_alias_note.v1",
- schema_name="mesh.snapshot_fanout_alias_note",
- schema_version="mesh.snapshot_fanout_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.snapshot_fanout_alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.snapshot_fanout_alias_note.v1",
- schema_name="mesh.snapshot_fanout_alias_note",
- schema_version="mesh.snapshot_fanout_alias_note.v1",
- )
- source = CultMesh.create_node(runtime_id="snapshot-alias-source")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:alias", CanonicalNote("from-source"))
- target = CultMesh.create_node(runtime_id="snapshot-alias-target")
- target.database.register_document(document)
- server = CultMesh.serve_node(source)
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="snapshot-alias-source",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("read-replica",),
- ))
-
- values = CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0).sync_documents(
- target.database,
- peers,
- verse_id="mesh",
- roles=["read-replica"],
- documents=[(alias, "note:alias")],
- )
- finally:
- server.stop()
-
- self.assertEqual(len(values), 1)
- self.assertIsInstance(values[0], UiNote)
- self.assertEqual(values[0].body, "from-source")
- self.assertEqual(target.database.get_required(document, "note:alias").body, "from-source")
- self.assertIsInstance(target.database.get_required(alias, "note:alias"), UiNote)
-
- def test_cultmesh_document_subscription_syncs_alias_snapshot_and_changes(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.subscription_alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.subscription_alias_note.v1",
- schema_name="mesh.subscription_alias_note",
- schema_version="mesh.subscription_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.subscription_alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.subscription_alias_note.v1",
- schema_name="mesh.subscription_alias_note",
- schema_version="mesh.subscription_alias_note.v1",
- )
- source = CultMesh.create_node(runtime_id="subscription-alias-source")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
- target = CultMesh.create_node(runtime_id="subscription-alias-target")
- target.database.register_document(document)
- server = CultMesh.serve_node(source)
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
- initial = subscription.sync_initial()
- subscription.send(document_put_raw(
- message_id="subscription-alias-put",
- key="note:live",
- schema_id="mesh.subscription_alias_note.v1",
- stored_at="2026-06-14T00:00:00Z",
- payload=document.encode_payload(CanonicalNote("updated")),
- ))
- updated = subscription.read_next_change()
- subscription.send(document_delete(
- message_id="subscription-alias-delete",
- schema_id="mesh.subscription_alias_note.v1",
- record_key="note:live",
- ))
- removed = subscription.read_next_change()
- finally:
- server.stop()
-
- self.assertIsInstance(initial, UiNote)
- self.assertEqual(initial.body, "initial")
- self.assertEqual(updated.change_kind, "updated")
- self.assertIsInstance(updated.value, UiNote)
- self.assertEqual(updated.value.body, "updated")
- self.assertIsInstance(updated.previous_value, UiNote)
- self.assertEqual(updated.previous_value.body, "initial")
- self.assertEqual(removed.change_kind, "removed")
- self.assertIsNone(removed.value)
- self.assertIsInstance(removed.previous_value, UiNote)
- self.assertEqual(removed.previous_value.body, "updated")
- self.assertIsNone(target.database.get(alias, "note:live"))
-
- def test_cultmesh_snapshot_fanout_syncs_once_and_reports_applied_records(self) -> None:
- document = define_database_entry_type(
- "mesh.snapshot_loop_note",
- [("body", 0)],
- schema_id="mesh.snapshot_loop_note.v1",
- )
- source = CultMesh.create_node(runtime_id="snapshot-loop-source")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:sync-once", {"body": "from-source"})
- target = CultMesh.create_node(runtime_id="snapshot-loop-target")
- target.database.register_document(document)
- server = CultMesh.serve_node(source)
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="snapshot-loop-source",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("read-replica",),
- ))
- seen: list[str] = []
- fanout = CultMeshSnapshotFanout(
- CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
- target.database,
- peers,
- verse_id="mesh",
- roles=["read-replica"],
- schema_ids=["mesh.snapshot_loop_note.v1"],
- on_applied=lambda record: seen.append(record.record_key),
- )
-
- applied = fanout.sync_once()
- finally:
- server.stop()
-
- self.assertEqual([record.record_key for record in applied], ["note:sync-once"])
- self.assertEqual(seen, ["note:sync-once"])
- self.assertEqual(target.database.get_required(document, "note:sync-once")["body"], "from-source")
-
- def test_cultmesh_snapshot_fanout_syncs_typed_documents_and_reports_alias_values(self) -> None:
- @dataclass
- class CanonicalNote:
- body: str
-
- @dataclass
- class UiNote:
- body: str
-
- document = define_database_entry_type(
- "mesh.snapshot_loop_alias_note",
- [("body", 0)],
- cls=CanonicalNote,
- schema_id="mesh.snapshot_loop_alias_note.v1",
- schema_name="mesh.snapshot_loop_alias_note",
- schema_version="mesh.snapshot_loop_alias_note.v1",
- )
- alias = define_database_entry_type(
- "mesh.snapshot_loop_alias_note.ui",
- [("body", 0)],
- cls=UiNote,
- schema_id="mesh.snapshot_loop_alias_note.v1",
- schema_name="mesh.snapshot_loop_alias_note",
- schema_version="mesh.snapshot_loop_alias_note.v1",
- )
- source = CultMesh.create_node(runtime_id="snapshot-loop-alias-source")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:typed-loop", CanonicalNote("from-source"))
- target = CultMesh.create_node(runtime_id="snapshot-loop-alias-target")
- target.database.register_document(document)
- server = CultMesh.serve_node(source)
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="snapshot-loop-alias-source",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("read-replica",),
- ))
- seen: list[UiNote] = []
- fanout = CultMeshSnapshotFanout(
- CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
- target.database,
- peers,
- verse_id="mesh",
- roles=["read-replica"],
- documents=[(alias, "note:typed-loop")],
- on_document=seen.append,
- )
-
- values = fanout.sync_once()
- finally:
- server.stop()
-
- self.assertEqual(len(values), 1)
- self.assertIsInstance(values[0], UiNote)
- self.assertEqual(values[0].body, "from-source")
- self.assertEqual(seen, [values[0]])
- self.assertEqual(target.database.get_required(document, "note:typed-loop").body, "from-source")
- self.assertIsInstance(target.database.get_required(alias, "note:typed-loop"), UiNote)
-
- def test_cultmesh_snapshot_fanout_runs_background_loop(self) -> None:
- document = define_database_entry_type(
- "mesh.snapshot_background_note",
- [("body", 0)],
- schema_id="mesh.snapshot_background_note.v1",
- )
- source = CultMesh.create_node(runtime_id="snapshot-background-source")
- source.database.register_document(document)
- source.database.put_raw_message(document, "note:background", {"body": "from-background"})
- target = CultMesh.create_node(runtime_id="snapshot-background-target")
- target.database.register_document(document)
- server = CultMesh.serve_node(source)
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="snapshot-background-source",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("read-replica",),
- ))
- seen: list[str] = []
- fanout = CultMeshSnapshotFanout(
- CultMeshDiscoveryClient("127.0.0.1", server.port, timeout_seconds=2.0),
- target.database,
- peers,
- verse_id="mesh",
- roles=["read-replica"],
- schema_ids=["mesh.snapshot_background_note.v1"],
- interval_seconds=0.05,
- on_applied=lambda record: seen.append(record.record_key),
- )
- fanout.start()
- deadline = time.monotonic() + 2.0
- while not seen and time.monotonic() < deadline:
- time.sleep(0.01)
- fanout.stop()
- finally:
- server.stop()
-
- self.assertTrue(seen)
- self.assertEqual(target.database.get_required(document, "note:background")["body"], "from-background")
-
- def test_cultmesh_peer_health_monitor_probes_reachable_and_failed_endpoints(self) -> None:
- document = define_database_entry_type(
- "mesh.health_note",
- [("body", 0)],
- schema_id="mesh.health_note.v1",
- )
- node = CultMesh.create_node(runtime_id="health-peer")
- node.database.register_document(document)
- server = CultMesh.serve_node(node)
- closed_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- closed_socket.bind(("127.0.0.1", 0))
- closed_port = closed_socket.getsockname()[1]
- closed_socket.close()
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="health-peer",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("read-replica",),
- ))
- peers.upsert(CultMeshPeerCard(
- peer_id="closed-peer",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{closed_port}",),
- roles=("read-replica",),
- ))
- seen: list[tuple[str, bool]] = []
- monitor = CultMeshPeerHealthMonitor(
- runtime_id="health-prober",
- timeout_seconds=0.2,
- on_update=lambda health: seen.append((health.peer_id, health.is_reachable)),
- )
-
- results = monitor.probe_catalog(peers, verse_id="mesh", roles=["read-replica"])
- finally:
- server.stop()
-
- by_peer = {result.peer_id: result for result in results}
- self.assertTrue(by_peer["health-peer"].is_reachable)
- self.assertEqual(by_peer["health-peer"].runtime_id, "health-peer")
- self.assertEqual(by_peer["health-peer"].runtime_kind, "python")
- self.assertEqual(by_peer["health-peer"].display_name, "health-peer")
- self.assertEqual(by_peer["health-peer"].supported_document_types, ("mesh.health_note",))
- self.assertIn("cultnet.hello.v0", by_peer["health-peer"].supported_message_versions)
- self.assertIn("cultnet.error.v0", by_peer["health-peer"].supported_message_versions)
- self.assertEqual(by_peer["health-peer"].supported_mutation_contracts[0]["documentType"], "mesh.health_note")
- self.assertIn("shardLog", by_peer["health-peer"].supported_mutation_contracts[0]["operations"])
- self.assertFalse(by_peer["closed-peer"].is_reachable)
- self.assertIsNone(by_peer["closed-peer"].runtime_kind)
- self.assertEqual(by_peer["closed-peer"].supported_document_types, ())
- self.assertEqual(by_peer["closed-peer"].supported_message_versions, ())
- self.assertEqual(by_peer["closed-peer"].supported_mutation_contracts, ())
- self.assertTrue(by_peer["closed-peer"].error)
- self.assertEqual({peer_id for peer_id, _ in seen}, {"health-peer", "closed-peer"})
- self.assertEqual(len(monitor.latest()), 2)
-
- def test_cultmesh_peer_health_monitor_runs_background_probe_loop(self) -> None:
- server = CultMesh.serve_node(CultMesh.create_node(runtime_id="health-loop-peer"))
- try:
- peers = CultMeshPeerCatalog()
- peers.upsert(CultMeshPeerCard(
- peer_id="health-loop-peer",
- verse_id="mesh",
- endpoints=(f"cultnet://127.0.0.1:{server.port}",),
- roles=("read-replica",),
- ))
- seen: list[str] = []
- monitor = CultMeshPeerHealthMonitor(
- runtime_id="health-loop-prober",
- timeout_seconds=0.2,
- interval_seconds=0.05,
- on_update=lambda health: seen.append(health.peer_id),
- )
- monitor.start(peers, verse_id="mesh", roles=["read-replica"])
- deadline = time.monotonic() + 2.0
- while time.monotonic() < deadline:
- latest = monitor.latest()
- if latest and latest[0].is_reachable:
- break
- time.sleep(0.01)
- monitor.stop()
- finally:
- server.stop()
-
- self.assertTrue(seen)
- latest = monitor.latest()
- self.assertEqual(latest[0].peer_id, "health-loop-peer")
- self.assertTrue(latest[0].is_reachable)
-
- def test_cultmesh_daemon_entrypoint_serves_framed_cultnet_hello(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- ready_path = Path(temp) / "ready.json"
- package_src = Path(__file__).resolve().parents[1] / "src"
- env = dict(os.environ)
- existing_pythonpath = env.get("PYTHONPATH")
- env["PYTHONPATH"] = (
- str(package_src)
- if not existing_pythonpath
- else f"{package_src}{os.pathsep}{existing_pythonpath}"
- )
- process = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "daemon-test-peer",
- "--display-name",
- "Daemon Test Peer",
- "--port",
- "0",
- "--ready-file",
- str(ready_path),
- "--seed-interop-note",
- "--seed-shard-id",
- "daemon-interop",
- "--verse-id",
- "daemon-verse",
- "--verse-display-name",
- "Daemon Verse",
- "--role",
- "shard-primary",
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- deadline = time.monotonic() + 5.0
- while not ready_path.exists() and time.monotonic() < deadline:
- if process.poll() is not None:
- break
- time.sleep(0.05)
- if not ready_path.exists():
- _, stderr = process.communicate(timeout=2.0)
- self.fail(f"cultmesh daemon did not publish readiness: {stderr}")
- ready = json.loads(ready_path.read_text(encoding="utf-8"))
- self.assertEqual(ready["schemaVersion"], READY_SCHEMA_VERSION)
- self.assertEqual(ready["runtimeId"], "daemon-test-peer")
- self.assertEqual(ready["runtimeKind"], "python")
- self.assertEqual(ready["displayName"], "Daemon Test Peer")
- self.assertEqual(ready["endpoint"], f"cultnet://127.0.0.1:{ready['port']}")
- self.assertEqual(ready["shardIds"], ["daemon-interop"])
- self.assertEqual(ready["supportedDocumentTypes"], ["cultcache.interop-note"])
- self.assertIn("cultnet.hello.v0", ready["supportedMessageVersions"])
- self.assertIn("cultnet.document_put_raw.v0", ready["supportedMessageVersions"])
- self.assertNotIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"])
- self.assertEqual(ready["supportedMutationContracts"][0]["documentType"], "cultcache.interop-note")
- self.assertIn("shardLog", ready["supportedMutationContracts"][0]["operations"])
- self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": None})
- self.assertEqual(ready["verses"][0]["verseId"], "daemon-verse")
- self.assertEqual(ready["verses"][0]["displayName"], "Daemon Verse")
- self.assertEqual(ready["verses"][0]["discoveryEndpoints"], [ready["endpoint"]])
- self.assertEqual(ready["verses"][0]["authorityRuntimeIds"], ["daemon-test-peer"])
- self.assertEqual(ready["peers"][0]["peerId"], "daemon-test-peer")
- self.assertEqual(ready["peers"][0]["endpoints"], [ready["endpoint"]])
- self.assertEqual(ready["peers"][0]["roles"], ["shard-primary"])
- self.assertEqual(ready["peers"][0]["shardIds"], ["daemon-interop"])
-
- client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
- hello_response = client.request(
- hello(runtime_id="daemon-test-prober"),
- expected_schema_version="cultnet.hello.v0",
- )
-
- self.assertEqual(hello_response["runtimeId"], "daemon-test-peer")
- self.assertEqual(hello_response["displayName"], "Daemon Test Peer")
- self.assertIn("cultnet.hello.v0", hello_response["supportedMessageVersions"])
- self.assertIn("cultnet.schema_catalog_request.v0", hello_response["supportedMessageVersions"])
- self.assertNotIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
- self.assertEqual(hello_response["supportedDocumentTypes"], ["cultcache.interop-note"])
- self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
- self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], int(ready["port"]))
- self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"])
- self.assertEqual(ready["supportedMutationContracts"], hello_response["supportedMutationContracts"])
- self.assertEqual(ready["transportProfiles"], hello_response["transportProfiles"])
-
- schema_response = client.fetch_schema_catalog(schema_ids=["cultcache.interop-note"], include_schema_json=True)
- self.assertEqual(schema_response["schemas"][0]["schemaVersion"], INTEROP_SCHEMA_VERSION)
- self.assertEqual(schema_response["schemas"][0]["documentType"], "cultcache.interop-note")
-
- snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
- self.assertEqual(snapshot_response.documents[0].record_key, "note:daemon-test-peer")
- note = interop_note_document.decode_payload(snapshot_response.documents[0].payload)
- self.assertEqual(note["authorRuntimeId"], "daemon-test-peer")
- self.assertIn("daemon", note["tags"])
-
- shard_catalog = client.fetch_shard_catalog(schema_ids=["cultcache.interop-note"])
- self.assertEqual(shard_catalog["shards"][0]["shardId"], "daemon-interop")
- self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["cultcache.interop-note"])
-
- shard_log = client.fetch_shard_log_response(shard_id="daemon-interop", shard_epoch=1)
- self.assertFalse(shard_log.resync_required)
- self.assertEqual(shard_log.entries[0].sequence, 1)
- self.assertEqual(shard_log.entries[0].change_kind, "added")
- self.assertIsNotNone(shard_log.entries[0].raw_document)
- assert shard_log.entries[0].raw_document is not None
- self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:daemon-test-peer")
-
- discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
- verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
- self.assertEqual(verses[0].verse_id, "daemon-verse")
- self.assertEqual(verses[0].display_name, "Daemon Verse")
- self.assertEqual(verses[0].discovery_endpoints, (ready["endpoint"],))
- self.assertEqual(verses[0].authority_runtime_ids, ("daemon-test-peer",))
- self.assertEqual([verse.to_wire() for verse in verses], ready["verses"])
-
- peers = discovery_client.fetch_peers(verse_id="daemon-verse", roles=["shard-primary"])
- self.assertEqual(peers[0].peer_id, "daemon-test-peer")
- self.assertEqual(peers[0].endpoints, (ready["endpoint"],))
- self.assertEqual(peers[0].roles, ("shard-primary",))
- self.assertEqual(peers[0].shard_ids, ("daemon-interop",))
- self.assertEqual([peer.to_wire() for peer in peers], ready["peers"])
-
- monitor = CultMeshPeerHealthMonitor(runtime_id="daemon-health-prober", timeout_seconds=2.0)
- health = monitor.probe_peer(peers[0])[0]
- self.assertTrue(health.is_reachable)
- self.assertEqual(health.runtime_id, "daemon-test-peer")
- self.assertEqual(health.supported_document_types, ("cultcache.interop-note",))
- self.assertEqual(health.supported_mutation_contracts[0]["documentType"], "cultcache.interop-note")
- finally:
- process.terminate()
- try:
- process.communicate(timeout=5.0)
- except subprocess.TimeoutExpired:
- process.kill()
- process.communicate(timeout=5.0)
-
- def test_cultmesh_daemon_restarts_with_persisted_cache_and_shard_log(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- cache_path = root / "mesh.cc"
- shard_log_path = root / "shard-log"
- env = dict(os.environ)
- package_src = Path(__file__).resolve().parents[1] / "src"
- existing_pythonpath = env.get("PYTHONPATH")
- env["PYTHONPATH"] = (
- str(package_src)
- if not existing_pythonpath
- else f"{package_src}{os.pathsep}{existing_pythonpath}"
- )
-
- first_ready = root / "first-ready.json"
- first = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "durable-daemon-peer",
- "--port",
- "0",
- "--cache-file",
- str(cache_path),
- "--enable-durable-shard-logs",
- "--shard-log-file",
- str(shard_log_path),
- "--seed-interop-note",
- "--seed-shard-id",
- "durable-interop",
- "--ready-file",
- str(first_ready),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- self._wait_for_ready_file(first, first_ready)
- finally:
- self._terminate_process(first)
-
- second_ready = root / "second-ready.json"
- second = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "durable-daemon-peer",
- "--port",
- "0",
- "--cache-file",
- str(cache_path),
- "--enable-durable-shard-logs",
- "--shard-log-file",
- str(shard_log_path),
- "--register-interop-note",
- "--verse-id",
- "durable-verse",
- "--role",
- "shard-primary",
- "--ready-file",
- str(second_ready),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- ready = self._wait_for_ready_file(second, second_ready)
- client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
-
- snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
- self.assertEqual(snapshot_response.documents[0].record_key, "note:durable-daemon-peer")
- note = interop_note_document.decode_payload(snapshot_response.documents[0].payload)
- self.assertEqual(note["authorRuntimeId"], "durable-daemon-peer")
-
- shard_log = client.fetch_shard_log_response(shard_id="durable-interop", shard_epoch=1)
- self.assertFalse(shard_log.resync_required)
- self.assertEqual(shard_log.entries[0].sequence, 1)
- self.assertIsNotNone(shard_log.entries[0].raw_document)
- assert shard_log.entries[0].raw_document is not None
- self.assertEqual(shard_log.entries[0].raw_document.record_key, "note:durable-daemon-peer")
-
- discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
- peers = discovery_client.fetch_peers(verse_id="durable-verse", roles=["shard-primary"])
- self.assertEqual(peers[0].shard_ids, ("durable-interop",))
- finally:
- self._terminate_process(second)
-
- def test_cultmesh_daemon_serves_live_database_subscription_mutations(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- ready_path = Path(temp) / "ready.json"
- package_src = Path(__file__).resolve().parents[1] / "src"
- env = dict(os.environ)
- existing_pythonpath = env.get("PYTHONPATH")
- env["PYTHONPATH"] = (
- str(package_src)
- if not existing_pythonpath
- else f"{package_src}{os.pathsep}{existing_pythonpath}"
- )
- process = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "live-daemon-peer",
- "--port",
- "0",
- "--seed-interop-note",
- "--seed-shard-id",
- "live-interop",
- "--ready-file",
- str(ready_path),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- ready = self._wait_for_ready_file(process, ready_path)
- client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
- live_note = {
- "schemaVersion": INTEROP_SCHEMA_VERSION,
- "documentId": "note:daemon-live",
- "authorRuntimeId": "daemon-live-client",
- "title": "daemon live subscription note",
- "body": "live subscription put from Python daemon smoke",
- "tags": ["daemon", "subscription", "interop"],
- }
-
- with client.subscribe_database(
- subscription_id="daemon-live-sub",
- schema_ids=["cultcache.interop-note"],
- ) as subscription:
- snapshot = subscription.read_next_snapshot_response()
- subscription.send(document_put_raw(
- message_id="daemon-live-put",
- key="note:daemon-live",
- schema_id="cultcache.interop-note",
- stored_at="2026-06-14T00:00:00Z",
- payload=interop_note_document.encode_payload(live_note),
- source_runtime_id="daemon-live-client",
- shard_id="live-interop",
- shard_epoch=1,
- ))
- change = subscription.read_next_change()
-
- self.assertEqual(snapshot.documents[0].record_key, "note:live-daemon-peer")
- self.assertEqual(change.change_kind, "added")
- self.assertEqual(change.record_key, "note:daemon-live")
- self.assertIsNotNone(change.raw_document)
- assert change.raw_document is not None
- self.assertEqual(
- interop_note_document.decode_payload(change.raw_document.payload),
- live_note,
- )
-
- shard_log = client.fetch_shard_log_response(shard_id="live-interop", shard_epoch=1)
- self.assertFalse(shard_log.resync_required)
- self.assertEqual([entry.sequence for entry in shard_log.entries], [1, 2])
- self.assertEqual(shard_log.entries[1].change_kind, "added")
- self.assertIsNotNone(shard_log.entries[1].raw_document)
- assert shard_log.entries[1].raw_document is not None
- self.assertEqual(shard_log.entries[1].raw_document.record_key, "note:daemon-live")
- finally:
- self._terminate_process(process)
-
- def test_cultmesh_daemon_serves_compacted_shard_log_resync_boundary(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- root = Path(temp)
- cache_path = root / "mesh.cc"
- shard_log_path = root / "shard-log"
- store = CultNetFileShardMutationLogStore(shard_log_path)
- node = CultMesh.create_node(
- cache_path,
- runtime_id="compacted-daemon-peer",
- enable_durable_shard_logs=True,
- shard_log_path=shard_log_path,
- )
- node.database.register_document(interop_note_document)
- node.database.put_raw_message(
- interop_note_document,
- "note:compacted-one",
- {
- "schemaVersion": INTEROP_SCHEMA_VERSION,
- "documentId": "note:compacted-one",
- "authorRuntimeId": "compacted-daemon-peer",
- "title": "compacted daemon note one",
- "body": "first retained cache value",
- "tags": ["daemon", "compaction"],
- },
- message_id="compacted-put-1",
- shard_id="compacted-interop",
- shard_epoch=7,
- )
- node.database.put_raw_message(
- interop_note_document,
- "note:compacted-two",
- {
- "schemaVersion": INTEROP_SCHEMA_VERSION,
- "documentId": "note:compacted-two",
- "authorRuntimeId": "compacted-daemon-peer",
- "title": "compacted daemon note two",
- "body": "second retained cache value",
- "tags": ["daemon", "compaction"],
- },
- message_id="compacted-put-2",
- shard_id="compacted-interop",
- shard_epoch=7,
- )
- store.compact_through("compacted-interop", 1)
-
- ready_path = root / "ready.json"
- package_src = Path(__file__).resolve().parents[1] / "src"
- env = dict(os.environ)
- existing_pythonpath = env.get("PYTHONPATH")
- env["PYTHONPATH"] = (
- str(package_src)
- if not existing_pythonpath
- else f"{package_src}{os.pathsep}{existing_pythonpath}"
- )
- process = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "compacted-daemon-peer",
- "--port",
- "0",
- "--cache-file",
- str(cache_path),
- "--enable-durable-shard-logs",
- "--shard-log-file",
- str(shard_log_path),
- "--register-interop-note",
- "--ready-file",
- str(ready_path),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- ready = self._wait_for_ready_file(process, ready_path)
- client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
-
- snapshot_response = client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
- self.assertEqual(
- {document.record_key for document in snapshot_response.documents},
- {"note:compacted-one", "note:compacted-two"},
- )
-
- compacted = client.fetch_shard_log_response(
- shard_id="compacted-interop",
- shard_epoch=7,
- after_sequence=0,
- )
- retained = client.fetch_shard_log_response(
- shard_id="compacted-interop",
- shard_epoch=7,
- after_sequence=1,
- )
-
- self.assertTrue(compacted.resync_required)
- self.assertEqual(compacted.reason, "compacted_history")
- self.assertEqual(compacted.compacted_through, 1)
- self.assertEqual(compacted.entries, ())
- self.assertFalse(retained.resync_required)
- self.assertEqual([entry.sequence for entry in retained.entries], [2])
- self.assertIsNotNone(retained.entries[0].raw_document)
- assert retained.entries[0].raw_document is not None
- self.assertEqual(retained.entries[0].raw_document.record_key, "note:compacted-two")
- finally:
- self._terminate_process(process)
-
- def test_cultmesh_daemon_accepts_schema_write_forwarder_mutations(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- ready_path = Path(temp) / "ready.json"
- package_src = Path(__file__).resolve().parents[1] / "src"
- env = dict(os.environ)
- existing_pythonpath = env.get("PYTHONPATH")
- env["PYTHONPATH"] = (
- str(package_src)
- if not existing_pythonpath
- else f"{package_src}{os.pathsep}{existing_pythonpath}"
- )
- process = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "forwarder-daemon-peer",
- "--port",
- "0",
- "--register-interop-note",
- "--ready-file",
- str(ready_path),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- ready = self._wait_for_ready_file(process, ready_path)
- endpoint = f"cultnet://127.0.0.1:{ready['port']}"
- shard = CultNetShardDescriptor(
- shard_id="forwarded-interop",
- owner_runtime_id="forwarder-daemon-peer",
- epoch=3,
- is_primary=False,
- schema_ids=("cultcache.interop-note",),
- primary_endpoints=(endpoint,),
- )
- forwarder: CultNetShardWriteForwarder = CultNetSchemaWriteForwarder(timeout_seconds=2.0)
- note = {
- "schemaVersion": INTEROP_SCHEMA_VERSION,
- "documentId": "note:forwarded-daemon",
- "authorRuntimeId": "forwarding-client",
- "title": "forwarded daemon note",
- "body": "forwarded through the schema write-forwarder",
- "tags": ["daemon", "forwarded"],
- }
-
- forwarder.forward_put(shard, document_put_raw(
- message_id="forwarded-daemon-put",
- key="note:forwarded-daemon",
- schema_id="cultcache.interop-note",
- stored_at="2026-06-14T00:00:00Z",
- payload=interop_note_document.encode_payload(note),
- source_runtime_id="forwarding-client",
- ).to_wire())
-
- client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
- self._eventually(lambda: bool(client.fetch_snapshot_response(
- schema_ids=["cultcache.interop-note"],
- record_keys=["note:forwarded-daemon"],
- ).documents))
- snapshot = client.fetch_snapshot_response(
- schema_ids=["cultcache.interop-note"],
- record_keys=["note:forwarded-daemon"],
- )
- self.assertEqual(
- interop_note_document.decode_payload(snapshot.documents[0].payload),
- note,
- )
-
- forwarder.forward_delete(shard, document_delete(
- message_id="forwarded-daemon-delete",
- schema_id="cultcache.interop-note",
- record_key="note:forwarded-daemon",
- ).to_wire())
- self._eventually(lambda: not client.fetch_snapshot_response(
- schema_ids=["cultcache.interop-note"],
- record_keys=["note:forwarded-daemon"],
- ).documents)
-
- shard_log = client.fetch_shard_log_response(shard_id="forwarded-interop", shard_epoch=3)
- self.assertEqual([entry.change_kind for entry in shard_log.entries], ["added", "removed"])
- self.assertEqual(shard_log.entries[0].put["shardId"], "forwarded-interop")
- self.assertEqual(shard_log.entries[0].put["shardEpoch"], 3)
- self.assertEqual(shard_log.entries[1].delete["shardId"], "forwarded-interop")
- self.assertEqual(shard_log.entries[1].delete["shardEpoch"], 3)
- finally:
- self._terminate_process(process)
-
- def test_cultmesh_daemon_enforces_snapshot_document_limit_as_peer_error(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- ready_path = Path(temp) / "ready.json"
- package_src = Path(__file__).resolve().parents[1] / "src"
- env = dict(os.environ)
- existing_pythonpath = env.get("PYTHONPATH")
- env["PYTHONPATH"] = (
- str(package_src)
- if not existing_pythonpath
- else f"{package_src}{os.pathsep}{existing_pythonpath}"
- )
- process = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "limited-daemon-peer",
- "--port",
- "0",
- "--seed-interop-note",
- "--max-snapshot-documents",
- "0",
- "--ready-file",
- str(ready_path),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- ready = self._wait_for_ready_file(process, ready_path)
- self.assertEqual(ready["snapshotLimits"], {"maxSnapshotBytes": None, "maxSnapshotDocuments": 0})
- client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
-
- with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded") as raised:
- client.fetch_snapshot_response(schema_ids=["cultcache.interop-note"])
-
- self.assertEqual(raised.exception.response["schemaVersion"], "cultnet.error.v0")
- self.assertEqual(raised.exception.response["code"], "snapshot_document_limit_exceeded")
- self.assertEqual(raised.exception.response["details"]["documentCount"], 1)
- self.assertEqual(raised.exception.response["details"]["maxSnapshotDocuments"], 0)
- finally:
- self._terminate_process(process)
-
- def test_cultmesh_daemon_serves_opt_in_simulation_observations(self) -> None:
- with tempfile.TemporaryDirectory() as temp:
- ready_path = Path(temp) / "ready.json"
- package_src = Path(__file__).resolve().parents[1] / "src"
- env = dict(os.environ)
- existing_pythonpath = env.get("PYTHONPATH")
- env["PYTHONPATH"] = (
- str(package_src)
- if not existing_pythonpath
- else f"{package_src}{os.pathsep}{existing_pythonpath}"
- )
- process = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "cultmesh_py.daemon",
- "--runtime-id",
- "simulation-daemon-peer",
- "--port",
- "0",
- "--enable-simulation-observations",
- "--simulation-minimum-witnesses",
- "2",
- "--simulation-quorum-ratio",
- "1.0",
- "--verse-id",
- "simulation-verse",
- "--ready-file",
- str(ready_path),
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True,
- env=env,
- )
- try:
- ready = self._wait_for_ready_file(process, ready_path)
- self.assertIn("cultnet.simulation_observation.v0", ready["supportedMessageVersions"])
- self.assertIn("cultnet.simulation_consensus_candidate.v0", ready["supportedMessageVersions"])
- client = CultMesh.create_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
- hello_response = client.request(
- hello(runtime_id="simulation-daemon-prober"),
- expected_schema_version="cultnet.hello.v0",
- )
- self.assertEqual(ready["supportedMessageVersions"], hello_response["supportedMessageVersions"])
- discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", int(ready["port"]), timeout_seconds=2.0)
- peers = discovery_client.fetch_peers(verse_id="simulation-verse", roles=["simulation-observer"])
- self.assertEqual(peers[0].peer_id, "simulation-daemon-peer")
- self.assertIn("simulation-observer", peers[0].roles)
- self.assertIn("read-replica", peers[0].roles)
-
- claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
- first = client.request(
- simulation_observation(
- message_id="daemon-sim-1",
- witness_runtime_id="watcher-1",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ),
- expected_schema_version="cultnet.simulation_consensus_candidate.v0",
- )
- second = client.request(
- simulation_observation(
- message_id="daemon-sim-2",
- witness_runtime_id="watcher-2",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ),
- expected_schema_version="cultnet.simulation_consensus_candidate.v0",
- )
-
- self.assertEqual(first["messageId"], "daemon-sim-1")
- self.assertFalse(first["hasQuorum"])
- self.assertEqual(first["witnessCount"], 1)
- self.assertEqual(second["messageId"], "daemon-sim-2")
- self.assertTrue(second["hasQuorum"])
- self.assertEqual(second["witnessCount"], 2)
- self.assertEqual(second["claimHash"], claim_hash)
- finally:
- self._terminate_process(process)
-
- def _wait_for_ready_file(self, process: subprocess.Popen[str], ready_path: Path) -> dict[str, object]:
- deadline = time.monotonic() + 5.0
- while not ready_path.exists() and time.monotonic() < deadline:
- if process.poll() is not None:
- break
- time.sleep(0.05)
- if not ready_path.exists():
- _, stderr = process.communicate(timeout=2.0)
- self.fail(f"cultmesh daemon did not publish readiness: {stderr}")
- return json.loads(ready_path.read_text(encoding="utf-8"))
-
- def _terminate_process(self, process: subprocess.Popen[str]) -> None:
- process.terminate()
- try:
- process.communicate(timeout=5.0)
- except subprocess.TimeoutExpired:
- process.kill()
- process.communicate(timeout=5.0)
-
- def test_cultmesh_local_server_serves_node_and_catalogs_over_clients(self) -> None:
- document = define_database_entry_type(
- "mesh.server_note",
- [("body", 0)],
- schema_id="mesh.server_note.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-server")
- node.database.register_document(document)
- node.database.put_raw_message(document, "note:1", {"body": "served"}, shard_id="notes", shard_epoch=1)
- observation_hub = CultNetSimulationObservationHub(
- CultNetSimulationConsensusOptions(minimum_witnesses=2, quorum_ratio=1.0)
- )
- seen_candidates: list[CultNetSimulationConsensusCandidate] = []
- unsubscribe_candidates = observation_hub.watch_candidates(seen_candidates.append)
- claim_hash = compute_simulation_claim_hash("hit", "alice", "bob", "frame:100")
- verses = CultMeshVerseCatalog()
- verses.upsert(
- CultMeshVerseDescriptor(
- verse_id="server-verse",
- display_name="Server Verse",
- authority_model="local",
- compatibility=CultMeshVerseCompatibility("cultmesh.v0", "rules"),
- )
- )
- peers = CultMeshPeerCatalog()
- peers.upsert(
- CultMeshPeerCard(
- peer_id="mesh-server",
- verse_id="server-verse",
- endpoints=("cultnet://127.0.0.1:0",),
- roles=("read-replica",),
- )
- )
-
- server = CultMesh.serve_node(
- node,
- verse_catalog=verses,
- peer_catalog=peers,
- observation_hub=observation_hub,
- display_name="Mesh Server",
- )
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- hello_response = raw_client.request(hello(runtime_id="probe"), expected_schema_version="cultnet.hello.v0")
- schema_response = raw_client.fetch_schema_catalog(schema_ids=["mesh.server_note.v1"], include_schema_json=True)
- rudp_client = CultMesh.create_client(
- endpoint=f"rudp://127.0.0.1:{server.port}",
- timeout_seconds=2.0,
- connection_id=server.rudp_connection_id,
- runtime_id="rudp-probe",
- )
- rudp_hello_response = rudp_client.request(
- hello(runtime_id="rudp-probe"),
- expected_schema_version="cultnet.hello.v0",
- )
- rudp_schema_response = rudp_client.fetch_schema_catalog(
- schema_ids=["mesh.server_note.v1"],
- include_schema_json=True,
- )
- wire_schema_response = raw_client.fetch_schema_catalog(kinds=["wire_message"], include_schema_json=True)
- synced_schema_catalog = CultNetSchemaCatalog()
- synced_wire_descriptors = raw_client.sync_schema_catalog(
- synced_schema_catalog,
- kinds=["wire_message"],
- include_schema_json=True,
- )
- snapshot_response = raw_client.fetch_snapshot(schema_ids=["mesh.server_note.v1"])
- typed_snapshot_response = raw_client.fetch_snapshot_response(schema_ids=["mesh.server_note.v1"])
- shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.server_note.v1"])
- synced_shard_catalog = CultNetShardCatalog()
- synced_shards = raw_client.sync_shard_catalog(synced_shard_catalog, schema_ids=["mesh.server_note.v1"])
- shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=1)
- typed_shard_log = raw_client.fetch_shard_log_response(shard_id="notes", shard_epoch=1)
- self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["protocol"], "tcp_framed")
- self.assertEqual(hello_response["transportProfiles"][0]["transports"][0]["port"], server.port)
- stale_shard_log = raw_client.fetch_shard_log(shard_id="notes", shard_epoch=0)
- discovery_client = CultMesh.create_verse_discovery_client("127.0.0.1", server.port, timeout_seconds=2.0)
- fetched_verses = discovery_client.fetch_verses(transport_version="cultmesh.v0")
- fetched_peers = discovery_client.fetch_peers(verse_id="server-verse", roles=["read-replica"])
- first_candidate = raw_client.request(
- simulation_observation(
- message_id="obs-1",
- witness_runtime_id="watcher-1",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ),
- expected_schema_version="cultnet.simulation_consensus_candidate.v0",
- )
- quorum_candidate = raw_client.request(
- simulation_observation(
- message_id="obs-2",
- witness_runtime_id="watcher-2",
- shard_id="arena",
- shard_epoch=4,
- frame=100,
- subject_id="bob",
- claim_kind="hit",
- claim_hash=claim_hash,
- claim_summary="alice shot bob first",
- ),
- expected_schema_version="cultnet.simulation_consensus_candidate.v0",
- )
- with raw_client.subscribe_database(subscription_id="sub-server", schema_ids=["mesh.server_note.v1"]) as subscription:
- subscription_snapshot = subscription.read_next_snapshot_response()
- subscription.send(document_put_raw(
- message_id="server-put",
- key="note:2",
- schema_id="mesh.server_note.v1",
- stored_at="2026-06-14T00:00:00Z",
- payload=document.encode_payload({"body": "subscribed"}),
- shard_id="primary",
- shard_epoch=1,
- ))
- subscription_change = subscription.read_next_change()
- subscription.send(document_delete(
- message_id="server-delete",
- schema_id="mesh.server_note.v1",
- record_key="note:2",
- shard_id="primary",
- shard_epoch=1,
- ))
- subscription_delete = subscription.read_next_change()
- finally:
- unsubscribe_candidates()
- server.stop()
-
- self.assertEqual(hello_response["runtimeId"], "mesh-server")
- self.assertEqual(hello_response["displayName"], "Mesh Server")
- self.assertIn("cultnet.database_subscribe.v0", hello_response["supportedMessageVersions"])
- self.assertIn("cultnet.error.v0", hello_response["supportedMessageVersions"])
- self.assertIn("cultnet.document_put_raw.v0", hello_response["supportedMessageVersions"])
- self.assertIn("cultnet.simulation_observation.v0", hello_response["supportedMessageVersions"])
- self.assertEqual(hello_response["supportedMutationContracts"][0]["documentType"], "mesh.server_note")
- self.assertIn("documentDelete", hello_response["supportedMutationContracts"][0]["operations"])
- self.assertIn("shardLog", hello_response["supportedMutationContracts"][0]["operations"])
- advertised_transports = {
- transport["protocol"]: transport
- for profile in hello_response["transportProfiles"]
- for transport in profile["transports"]
- }
- self.assertEqual(advertised_transports["tcp_framed"]["port"], server.port)
- self.assertEqual(advertised_transports["rudp"]["port"], server.port)
- self.assertEqual(rudp_hello_response["runtimeId"], "mesh-server")
- self.assertEqual(rudp_schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1")
- self.assertEqual(schema_response["schemas"][0]["schemaId"], "mesh.server_note.v1")
- self.assertIn("schemaJson", schema_response["schemas"][0])
- self.assertIn("cultnet.document_delete.v0", schema_response["schemas"][0]["wireContracts"])
- self.assertIn("cultnet.shard_log_response.v0", schema_response["schemas"][0]["wireContracts"])
- wire_descriptors = {schema["schemaVersion"]: schema for schema in wire_schema_response["schemas"]}
- self.assertEqual(wire_descriptors["cultnet.error.v0"]["kind"], "wire_message")
- self.assertEqual(wire_descriptors["cultnet.document_put_raw.v0"]["kind"], "wire_message")
- self.assertIn("schemaJson", wire_descriptors["cultnet.document_put_raw.v0"])
- self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors)
- self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
- self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors})
- self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json"))
- self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1")
- self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1")
- self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1")
- self.assertEqual(shard_catalog["shards"][0]["schemaIds"], ["mesh.server_note.v1"])
- self.assertEqual(shard_catalog["shards"][0]["epoch"], 1)
- self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes")
- self.assertEqual(synced_shards[0].shard_id, "notes")
- self.assertEqual(synced_shards[0].epoch, 1)
- self.assertEqual(synced_shard_catalog.get("notes"), synced_shards[0])
- self.assertEqual(shard_log["entries"][0]["changeKind"], "added")
- self.assertTrue(stale_shard_log["resyncRequired"])
- self.assertEqual(stale_shard_log["reason"], "stale_epoch")
- self.assertEqual(stale_shard_log["shardEpoch"], 1)
- self.assertEqual(stale_shard_log["entries"], [])
- self.assertEqual(typed_shard_log.last_sequence, 1)
- self.assertEqual(typed_shard_log.entries[0].put["document"]["recordKey"], "note:1")
- self.assertIsInstance(typed_shard_log.entries[0].raw_document, CultNetRawDocumentRecord)
- self.assertEqual(typed_shard_log.entries[0].raw_document.record_key, "note:1")
- self.assertEqual(fetched_verses[0].verse_id, "server-verse")
- self.assertEqual(fetched_peers[0].peer_id, "mesh-server")
- self.assertEqual(first_candidate["messageId"], "obs-1")
- self.assertFalse(first_candidate["hasQuorum"])
- self.assertEqual(quorum_candidate["messageId"], "obs-2")
- self.assertEqual(quorum_candidate["claimHash"], claim_hash)
- self.assertTrue(quorum_candidate["hasQuorum"])
- self.assertEqual(quorum_candidate["witnessCount"], 2)
- self.assertEqual([candidate.claim_hash for candidate in seen_candidates], [claim_hash, claim_hash])
- self.assertIsInstance(subscription_snapshot, CultNetRawSnapshotResponse)
- self.assertEqual(subscription_snapshot.to_wire()["schemaVersion"], "cultnet.snapshot_response_raw.v0")
- self.assertEqual(subscription_change.change_kind, "added")
- self.assertEqual(subscription_change.record_key, "note:2")
- self.assertEqual(subscription_change.document["recordKey"], "note:2")
- self.assertIsInstance(subscription_change.raw_document, CultNetRawDocumentRecord)
- self.assertEqual(subscription_change.raw_document.schema_id, "mesh.server_note.v1")
- self.assertEqual(subscription_delete.change_kind, "removed")
- self.assertEqual(subscription_delete.record_key, "note:2")
- self.assertIsNone(subscription_delete.raw_document)
- self.assertIsNone(node.database.get(document, "note:2"))
-
- def test_cultmesh_local_server_resolves_snapshot_and_shard_catalog_schema_aliases(self) -> None:
- document = define_database_entry_type(
- "mesh.alias_server_note",
- [("body", 0)],
- schema_id="sha256:mesh-alias-server-note",
- schema_name="mesh.alias_server_note",
- schema_version="mesh.alias_server_note.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-alias-server")
- node.database.register_document(document)
- node.database.put_raw_message(
- document,
- "note:alias",
- {"body": "served through alias"},
- shard_id="notes",
- shard_epoch=1,
- )
-
- server = CultMesh.serve_node(node)
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- snapshot = raw_client.fetch_snapshot(schema_ids=["mesh.alias_server_note.v1"])
- typed_snapshot = raw_client.fetch_snapshot_response(schema_ids=["mesh.alias_server_note.v1"])
- shard_catalog = raw_client.fetch_shard_catalog(schema_ids=["mesh.alias_server_note.v1"])
- finally:
- server.stop()
-
- local_schema_id = document.catalog_entry().schema_id
- self.assertEqual(snapshot["documents"][0]["schemaId"], local_schema_id)
- self.assertEqual(snapshot["documents"][0]["recordKey"], "note:alias")
- self.assertEqual(typed_snapshot.documents[0].schema_id, local_schema_id)
- self.assertEqual(shard_catalog["shards"][0]["schemaIds"], [local_schema_id])
- self.assertEqual(shard_catalog["shards"][0]["shardId"], "notes")
-
- def test_cultmesh_shard_snapshot_keeps_foreign_schema_alias_membership(self) -> None:
- document = define_database_entry_type(
- "mesh.alias_shard_note",
- [
- ("schema_version", 0),
- ("body", 1),
- ],
- schema_id="sha256:mesh-alias-shard-note",
- schema_name="mesh.alias_shard_note",
- schema_version="mesh.alias_shard_note.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-alias-shard-server")
- node.database.register_document(document)
-
- node.database.apply_raw_put_message(document_put_raw(
- message_id="put-foreign-shard-alias",
- key="note:foreign-shard",
- schema_id="runtime.generated.mesh.alias_shard_note.ui.99",
- stored_at="2026-06-27T00:00:00Z",
- payload=document.encode_payload({
- "schema_version": "mesh.alias_shard_note.v1",
- "body": "visible through shard snapshot",
- }),
- shard_id="notes",
- shard_epoch=1,
- ).to_wire())
- node.database.put_raw_message(
- document,
- "note:other-shard",
- {
- "schema_version": "mesh.alias_shard_note.v1",
- "body": "hidden by shard filter",
- },
- shard_id="other",
- shard_epoch=1,
- )
-
- response = node.database.build_snapshot_response(
- schema_ids=["mesh.alias_shard_note.v1"],
- shard_id="notes",
- shard_epoch=1,
- )
-
- local_schema_id = document.catalog_entry().schema_id
- self.assertEqual(response.shard_id, "notes")
- self.assertEqual(response.shard_epoch, 1)
- self.assertEqual([(record.schema_id, record.record_key) for record in response.documents], [
- (local_schema_id, "note:foreign-shard"),
- ])
- self.assertEqual(node.cache.get_required_envelope(document, "note:foreign-shard").schema_id, local_schema_id)
-
- node.database.apply_raw_delete_message(document_delete(
- message_id="delete-foreign-shard-alias",
- schema_id="mesh.alias_shard_note.v1",
- record_key="note:foreign-shard",
- shard_id="notes",
- shard_epoch=1,
- ).to_wire())
- deleted_response = node.database.build_snapshot_response(
- schema_ids=["mesh.alias_shard_note.v1"],
- shard_id="notes",
- shard_epoch=1,
- )
- self.assertEqual(deleted_response.documents, ())
-
- def test_cultmesh_local_server_notifies_schema_alias_subscription_for_raw_put(self) -> None:
- document = define_database_entry_type(
- "mesh.alias_sub_note",
- [
- ("schema_version", 0),
- ("body", 1),
- ],
- schema_id="sha256:mesh-alias-sub-note",
- schema_name="mesh.alias_sub_note",
- schema_version="mesh.alias_sub_note.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
- node.database.register_document(document)
-
- server = CultMesh.serve_node(node)
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- with raw_client.subscribe_database(
- subscription_id="sub-alias",
- schema_ids=["mesh.alias_sub_note.v1"],
- include_snapshot=False,
- ) as subscription:
- subscription.send(document_put_raw(
- message_id="put-foreign-alias",
- key="note:alias-sub",
- schema_id="runtime.generated.mesh.alias_sub_note.ui.99",
- stored_at="2026-06-27T00:00:00Z",
- payload=document.encode_payload({
- "schema_version": "mesh.alias_sub_note.v1",
- "body": "notified",
- }),
- ))
- change = subscription.read_next_change()
- finally:
- server.stop()
-
- local_schema_id = document.catalog_entry().schema_id
- self.assertEqual(change.subscription_id, "sub-alias")
- self.assertEqual(change.change_kind, "added")
- self.assertEqual(change.record_key, "note:alias-sub")
- self.assertIsInstance(change.raw_document, CultNetRawDocumentRecord)
- self.assertEqual(change.raw_document.schema_id, "runtime.generated.mesh.alias_sub_note.ui.99")
- self.assertEqual(node.database.get_required(document, "note:alias-sub")["body"], "notified")
- self.assertEqual(node.cache.get_required_envelope(document, "note:alias-sub").schema_id, local_schema_id)
-
- def test_cultmesh_local_server_notifies_schema_alias_subscription_for_delete(self) -> None:
- document = define_database_entry_type(
- "mesh.alias_delete_note",
- [("body", 0)],
- schema_id="sha256:mesh-alias-delete-note",
- schema_name="mesh.alias_delete_note",
- schema_version="mesh.alias_delete_note.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-alias-delete-server")
- node.database.register_document(document)
- node.database.put_raw_message(
- document,
- "note:delete-alias",
- {"body": "remove me"},
- shard_id="notes",
- shard_epoch=1,
- )
-
- server = CultMesh.serve_node(node)
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- with raw_client.subscribe_database(
- subscription_id="sub-delete-alias",
- schema_ids=[document.catalog_entry().schema_id],
- include_snapshot=False,
- ) as subscription:
- subscription.send(document_delete(
- message_id="delete-foreign-alias",
- schema_id="mesh.alias_delete_note.v1",
- record_key="note:delete-alias",
- shard_id="notes",
- shard_epoch=1,
- ))
- change = subscription.read_next_change()
- finally:
- server.stop()
-
- self.assertEqual(change.subscription_id, "sub-delete-alias")
- self.assertEqual(change.change_kind, "removed")
- self.assertEqual(change.schema_id, "mesh.alias_delete_note.v1")
- self.assertEqual(change.record_key, "note:delete-alias")
- self.assertIsNone(change.raw_document)
- self.assertIsNone(node.database.get(document, "note:delete-alias"))
-
- def test_cultmesh_local_server_rejects_oversized_snapshot_responses(self) -> None:
- document = define_database_entry_type(
- "mesh.snapshot_limit_note",
- [("body", 0)],
- schema_id="mesh.snapshot_limit_note.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-snapshot-limits")
- node.database.register_document(document)
- node.database.put_raw_message(document, "note:1", {"body": "first"})
- node.database.put_raw_message(document, "note:2", {"body": "second"})
-
- server = CultMesh.serve_node(node, max_snapshot_documents=1)
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- filtered_snapshot = raw_client.fetch_snapshot(record_keys=["note:1"])
- error = raw_client.request(
- snapshot_request(schema_ids=["mesh.snapshot_limit_note.v1"]),
- expected_schema_version="cultnet.error.v0",
- )
- with self.assertRaisesRegex(CultNetPeerError, "Snapshot document limit exceeded"):
- raw_client.fetch_snapshot(schema_ids=["mesh.snapshot_limit_note.v1"])
- finally:
- server.stop()
-
- self.assertEqual(filtered_snapshot["schemaVersion"], "cultnet.snapshot_response_raw.v0")
- self.assertEqual([document["recordKey"] for document in filtered_snapshot["documents"]], ["note:1"])
- self.assertEqual(error["schemaVersion"], "cultnet.error.v0")
- self.assertIn("Snapshot document limit exceeded", error["error"])
- self.assertEqual(error["code"], "snapshot_document_limit_exceeded")
- self.assertEqual(error["details"]["documentCount"], 2)
- self.assertEqual(error["details"]["maxSnapshotDocuments"], 1)
-
- def test_cultmesh_local_server_returns_errors_for_bad_requests(self) -> None:
- document = define_database_entry_type(
- "mesh.error_note",
- [("body", 0)],
- schema_id="mesh.error_note.v1",
- )
- node = CultMesh.create_node(runtime_id="mesh-error-server")
- node.database.register_document(document)
-
- server = CultMesh.serve_node(node)
- try:
- raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
- unsupported = raw_client.request(
- {"schemaVersion": "cultnet.nope.v0", "messageId": "bad-schema"},
- expected_schema_version="cultnet.error.v0",
- )
- malformed_put = raw_client.request(
- {"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "bad-put"},
- expected_schema_version="cultnet.error.v0",
- )
- malformed_delete = raw_client.request(
- {"schemaVersion": "cultnet.document_delete.v0", "messageId": "bad-delete", "schemaId": "mesh.error_note.v1"},
- expected_schema_version="cultnet.error.v0",
- )
- finally:
- server.stop()
-
- self.assertEqual(unsupported["messageId"], "bad-schema")
- self.assertEqual(unsupported["code"], "unsupported_schema_version")
- self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0")
- self.assertIn("Unsupported CultNet message schema", unsupported["error"])
- self.assertEqual(malformed_put["messageId"], "bad-put")
- self.assertEqual(malformed_put["code"], "malformed_document_put")
- self.assertEqual(malformed_delete["messageId"], "bad-delete")
- self.assertEqual(malformed_delete["code"], "malformed_document_delete")
-
- def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None:
- peer = CultMeshPeerCard(
- peer_id="voidbot-local",
- verse_id="local",
- endpoints=("cultmesh://localhost",),
- roles=("shard-primary",),
- authority_lease_id="lease:voidbot-local",
- )
- leases = CultMeshAuthorityLeaseCatalog()
- peers = CultMeshPeerCatalog()
- peers.upsert(peer)
- now = datetime.now(UTC)
- self.assertFalse(leases.is_authorized(peer, "shard-primary", at=now))
- self.assertEqual(peers.find_authorized("local", "shard-primary", leases, at=now), [])
-
- leases.upsert(
- CultMeshAuthorityLease(
- lease_id="lease:voidbot-local",
- verse_id="local",
- peer_id="voidbot-local",
- roles=("shard-primary", "shard-primary", ""),
- valid_from=now - timedelta(seconds=1),
- expires_at=now + timedelta(seconds=1),
- shard_ids=("shard-a",),
- issuer_runtime_id="odin",
- signature="signed",
- )
- )
-
- lease = leases.get("lease:voidbot-local")
- self.assertIsNotNone(lease)
- assert lease is not None
- self.assertEqual(leases.leases, (lease,))
- self.assertEqual(lease.roles, ("shard-primary",))
- self.assertEqual(lease.issuer_runtime_id, "odin")
- self.assertEqual(lease.signature, "signed")
- self.assertTrue(lease.is_valid_at(now))
- self.assertTrue(lease.covers(peer, "shard-primary", shard_id="shard-a", at=now))
- self.assertFalse(lease.covers(peer, "shard-primary", shard_id="shard-b", at=now))
- self.assertTrue(leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
- self.assertEqual(
- peers.find_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now),
- [peer],
- )
- self.assertEqual(
- peers.first_authorized("local", "shard-primary", leases, shard_id="shard-a", at=now),
- peer,
- )
- self.assertIsNone(peers.first_authorized("local", "read-replica", leases, at=now))
- self.assertFalse(leases.is_authorized(peer, "shard-primary", shard_id="shard-b", at=now))
- self.assertFalse(leases.is_authorized(peer, "read-replica", at=now))
- self.assertIsNone(leases.get("missing"))
-
- def test_cultmesh_authority_lease_catalog_can_require_verified_signatures(self) -> None:
- peer = CultMeshPeerCard(
- peer_id="voidbot-local",
- verse_id="local",
- endpoints=("cultmesh://localhost",),
- roles=("shard-primary",),
- shard_ids=("shard-a",),
- authority_lease_id="lease:voidbot-local",
- )
- now = datetime.now(UTC)
- unsigned = CultMeshAuthorityLease(
- lease_id="lease:voidbot-local",
- verse_id="local",
- peer_id="voidbot-local",
- roles=("shard-primary",),
- valid_from=now - timedelta(seconds=1),
- expires_at=now + timedelta(seconds=30),
- shard_ids=("shard-a",),
- issuer_runtime_id="odin",
- )
- verifier = CultMeshHmacAuthorityLeaseVerifier({"odin": b"lease-secret"})
- signed = verifier.issue(unsigned)
- strict_leases = CultMesh.create_authority_lease_catalog(
- signature_verifier=verifier.verify,
- require_verified_signatures=True,
- )
- strict_leases.upsert(unsigned)
- self.assertFalse(strict_leases.is_verified(unsigned.lease_id))
- self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
-
- strict_leases.upsert(signed)
- self.assertTrue(strict_leases.is_verified(signed.lease_id))
- self.assertTrue(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
-
- wrong_key = CultMesh.create_authority_lease_catalog(
- signature_verifier=CultMeshHmacAuthorityLeaseVerifier({"odin": b"wrong-secret"}).verify,
- require_verified_signatures=True,
- )
- wrong_key.upsert(signed)
- self.assertFalse(wrong_key.is_verified(signed.lease_id))
- self.assertFalse(wrong_key.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
-
- tampered = CultMeshAuthorityLease(
- lease_id=signed.lease_id,
- verse_id=signed.verse_id,
- peer_id=signed.peer_id,
- roles=("shard-primary", "read-replica"),
- valid_from=signed.valid_from,
- expires_at=signed.expires_at,
- shard_ids=signed.shard_ids,
- issuer_runtime_id=signed.issuer_runtime_id,
- signature=signed.signature,
- )
- strict_leases.upsert(tampered)
- self.assertFalse(strict_leases.is_verified(tampered.lease_id))
- self.assertFalse(strict_leases.is_authorized(peer, "shard-primary", shard_id="shard-a", at=now))
-
- def test_cultmesh_stream_catalog_negotiates_transport_and_latest_frame(self) -> None:
- streams = CultMeshStreamCatalog()
- streams.declare(
- CultMeshStreamDescriptor(
- stream_id="mimir:kiyo-pro",
- verse_id="studio",
- owner_peer_id="starfire",
- kind="video",
- label="Kiyo Pro",
- clock={"clockDomainId": "starfire-qpc", "confidence": 0.25},
- video={"width": 1920, "height": 1080, "pixelFormat": "YUY2", "framesPerSecond": 30},
- preferred_transports=("shared-d3d12-texture", "shared-memory", "cultcache-page"),
- max_in_flight_frames=3,
- )
- )
-
- negotiation = streams.negotiate(
- "mimir:kiyo-pro",
- CultMeshStreamConsumerProfile(
- peer_id="fensalir",
- verse_id="studio",
- supported_transports=("shared-d3d12-texture", "cultcache-page"),
- accepted_kinds=("video",),
- can_import_gpu_handles=True,
- max_in_flight_frames=2,
- ),
- )
-
- self.assertEqual(negotiation.transport, "shared-d3d12-texture")
- self.assertEqual(negotiation.max_in_flight_frames, 2)
- self.assertEqual(negotiation.copy_budget, "zero-copy-target")
-
- streams.publish_frame(
- CultMeshStreamFrameHandle(
- stream_id="mimir:kiyo-pro",
- sequence=42,
- timestamp_ns=1_000_000_000,
- duration_ns=33_333_334,
- transport="shared-d3d12-texture",
- native_handle="0xfeed",
- fence_handle="0xbeef",
- fence_value=7,
- unavoidable_copy_count=0,
- )
- )
- self.assertEqual(streams.latest_frame("mimir:kiyo-pro").sequence, 42)
-
if __name__ == "__main__":
unittest.main()
diff --git a/packages/cultcache-rs/Cargo.toml b/packages/cultcache-rs/Cargo.toml
index ef06727d..23d53801 100644
--- a/packages/cultcache-rs/Cargo.toml
+++ b/packages/cultcache-rs/Cargo.toml
@@ -1,7 +1,8 @@
[package]
name = "cultcache-rs"
-version = "0.1.0"
+version = "0.2.0"
edition = "2024"
+license = "MIT"
publish = false
[workspace]
@@ -17,14 +18,15 @@ chrono = { version = "0.4", features = ["serde"] }
cultcache-rs-derive = { path = "cultcache-rs-derive" }
fs2 = "0.4"
rmp-serde = "1.3"
+redb = "4.1.0"
serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
-[target.'cfg(windows)'.dependencies]
-windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
-
[dev-dependencies]
pretty_assertions = "1.4"
tempfile = "3"
+
+[target.'cfg(windows)'.dependencies]
+windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md
index 74b20dd3..1ad01eb0 100644
--- a/packages/cultcache-rs/README.md
+++ b/packages/cultcache-rs/README.md
@@ -53,7 +53,7 @@ struct Settings {
let mut cache = CultCache::new();
cache.register_entry_type::()?;
-cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
+cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"));
cache.pull_all_backing_stores()?;
cache.put("app", &Settings {
@@ -136,7 +136,7 @@ pub struct PlayerData {
let mut cache = CultCache::builder()
.with_generated_entries(game_entries())
- .with_generic_store(SingleFileMessagePackBackingStore::new("cache.msgpack"))
+ .with_generic_store(SingleFileMessagePackBackingStore::new("cache.cc"))
.build()?;
cache.pull_all_backing_stores()?;
@@ -173,39 +173,71 @@ without pretending Rust has runtime reflection hiding under the floorboards.
- `get_envelope::`
- `get_required_envelope::`
- `get_all::`
+- `get_all_with_keys::`
+- `soa::`
- `put::`
- `put_envelope::`
- `update::`
- `delete::`
- `snapshot`
+- `snapshot_soa`
+- `load_soa`
- `SingleFileMessagePackBackingStore`
+## Structure Of Arrays
+
+The C# CultCache hot path exposes CPU-local SoA scans as:
+
+```csharp
+Span x = cache.Soa()
+ .Column(nameof(PlayerTransform.PositionX))
+ .Span;
+```
+
+Rust cannot discover those columns through runtime reflection, so each domain
+type declares its column extractors explicitly with `SoaDocument` or the
+`cultcache_soa!` helper:
+
+```rust
+cultcache_soa!(PlayerTransform {
+ "position_x" => |row: &PlayerTransform| row.position_x,
+ "health" => |row: &PlayerTransform| row.health,
+});
+
+let table = cache.soa::()?;
+let health = table.column::("health")?;
+```
+
+This is the same public shape as the C# rite: callers ask the cache for a typed
+table, then pull contiguous typed columns by name. The Rust version is explicit
+about the extractor table because the closed-world schema boundary is a virtue,
+not a missing reflection party trick.
+
## Backing Store Routing
Generic store:
```rust
-cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.msgpack"));
+cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new("cache.cc"))?;
```
Type-specific store:
```rust
cache.add_backing_store(
- SingleFileMessagePackBackingStore::new("players.msgpack"),
+ SingleFileMessagePackBackingStore::new("players.cc"),
["player"],
-);
+)?;
```
-When writing a `PlayerData`, the cache checks type-specific stores first. If none
-match, it writes to the first generic store. Later matching stores are mirrors.
+The contract, shared with every runtime, is one home store per document type
+(`src/GameCult.Caching/Contracts/cultcache-store-composition.md` in CultLib):
-This mirrors the C# behavior:
-
-- specific domain stores own their domain
-- the first generic store is the primary generic write target
-- later generic stores mirror writes
-- this is not multi-master
+- a type-specific store owns its types; otherwise the generic store is the home
+- a second generic store is an error, and so is a type claimed by a second
+ store; both registrations return `Err` and attach nothing
+- a write touches exactly one store; there are no mirrors
+- a write whose type has no home store is an error
## Persistence Semantics
@@ -250,38 +282,8 @@ marked `default` use `Default::default()` when older payloads are read. Deleted
field slots should stay reserved until an explicit store migration rewrites the
data.
-The single-file MessagePack store rewrites one complete snapshot. Under the
-exclusive sidecar lock it removes abandoned candidates without following them,
-creates a unique same-directory candidate with create-new semantics, writes and
-flushes it, and then replaces the committed pathname without deleting that
-pathname first. A process interruption before replacement leaves the previous
-snapshot authoritative. Once replacement succeeds, readers observe the complete
-new snapshot rather than a partial file.
-
-Unix requests a parent-directory flush after replacement. Windows uses
-`MoveFileExW` with replace-existing and write-through flags. If the final Unix
-directory flush fails, the method returns a downcastable
-`CommittedSnapshotDurabilityUncertain`: the complete new snapshot may already be
-visible, so the caller must fail-stop or reopen and reconcile before issuing
-higher-level work again. Treating that error as "not committed" and blindly
-retrying can duplicate commands or receipts.
-
-Abandoned `..tmp` candidates are never read as committed state and are
-removed by the next exclusive writer. Cleanup recognizes only the exact
-canonical UUID form emitted for that target, including targets whose native
-filename is not UTF-8; similar `.tmp` names remain untouched. Candidate creation
-uses create-new semantics, so an unexpected file or symlink collision fails
-instead of being opened or truncated. The canonical candidate filename namespace
-is reserved to the store.
-
-These guarantees assume a local filesystem that honors the platform rename and
-flush primitives, and writers that honor the sidecar lock. Network filesystems,
-faulty storage hardware, and unrelated writers may provide weaker behavior.
-Fault-injection tests prove the process-visible old-or-new outcomes around the
-replacement boundary; they do not simulate sudden power loss or prove that a
-device honored a successful flush.
-
-This is a sane starting point for small typed state surfaces, settings, Epiphany agent memory,
+The single-file MessagePack store rewrites an atomic snapshot. That is a sane
+starting point for small typed state surfaces, settings, Epiphany agent memory,
heartbeat state, and other compact control-plane data. Large corpora should use
a sharded store or a real database instead of asking one file to become a
warehouse and then acting wounded when physics invoices us.
@@ -290,9 +292,6 @@ The single-file store uses a sidecar lock file for shared reads and exclusive
writes. That protects ordinary multi-process access to the same file. It is
still not a multi-master replication protocol, and it is not a substitute for a
coordinator when higher-level write ordering matters.
-An explicit unlock error cannot replace the write outcome: dropping the lock-file
-handle is the final OS lock-release path, so a committed-durability-uncertain
-result remains inspectable rather than being masked by cleanup noise.
## Near-Term Ergonomic Improvements
diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
index f05a23f3..7d613194 100644
--- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
+++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs
@@ -75,6 +75,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
};
let mut key: Option = None;
let mut default = false;
+ let mut bytes = false;
for attribute in field
.attrs
.iter()
@@ -88,6 +89,9 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
} else if meta.path.is_ident("default") {
default = true;
Ok(())
+ } else if meta.path.is_ident("bytes") {
+ bytes = true;
+ Ok(())
} else {
Err(meta.error("unsupported field cultcache attribute"))
}
@@ -117,6 +121,7 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
ident: field_ident,
ty: field.ty,
default,
+ bytes,
});
}
slots.sort_by_key(|slot| slot.key);
@@ -125,8 +130,14 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let serialize_elements = (0..=max_slot).map(|slot| {
if let Some(field) = slots.iter().find(|field| field.key == slot) {
let field_ident = &field.ident;
- quote! {
- tuple.serialize_element(&self.#field_ident)?;
+ if field.bytes {
+ quote! {
+ tuple.serialize_element(::serde_bytes::Bytes::new(&self.#field_ident))?;
+ }
+ } else {
+ quote! {
+ tuple.serialize_element(&self.#field_ident)?;
+ }
}
} else {
quote! {
@@ -138,8 +149,14 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let option_declarations = slots.iter().map(|field| {
let variable = format_ident!("slot_{}", field.key);
let ty = &field.ty;
- quote! {
- let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
+ if field.bytes {
+ quote! {
+ let mut #variable: ::std::option::Option<::serde_bytes::ByteBuf> = ::std::option::Option::None;
+ }
+ } else {
+ quote! {
+ let mut #variable: ::std::option::Option<#ty> = ::std::option::Option::None;
+ }
}
});
@@ -147,9 +164,17 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
if let Some(field) = slots.iter().find(|field| field.key == slot) {
let variable = format_ident!("slot_{}", field.key);
let ty = &field.ty;
- quote! {
- #slot => {
- #variable = seq.next_element::<#ty>()?;
+ if field.bytes {
+ quote! {
+ #slot => {
+ #variable = seq.next_element::<::serde_bytes::ByteBuf>()?;
+ }
+ }
+ } else {
+ quote! {
+ #slot => {
+ #variable = seq.next_element::<#ty>()?;
+ }
}
}
} else {
@@ -164,7 +189,16 @@ pub fn derive_database_entry(input: TokenStream) -> TokenStream {
let build_fields = slots.iter().map(|field| {
let field_ident = &field.ident;
let variable = format_ident!("slot_{}", field.key);
- if field.default {
+ if field.bytes && field.default {
+ quote! {
+ #field_ident: #variable.unwrap_or_default().into_vec()
+ }
+ } else if field.bytes {
+ let field_name = field_ident.to_string();
+ quote! {
+ #field_ident: #variable.ok_or_else(|| ::serde::de::Error::missing_field(#field_name))?.into_vec()
+ }
+ } else if field.default {
quote! {
#field_ident: #variable.unwrap_or_default()
}
@@ -240,4 +274,5 @@ struct CultCacheSlot {
ident: syn::Ident,
ty: Type,
default: bool,
+ bytes: bool,
}
diff --git a/packages/cultcache-rs/examples/cultcache_interop.rs b/packages/cultcache-rs/examples/cultcache_interop.rs
index 4049acf8..e625db9e 100644
--- a/packages/cultcache-rs/examples/cultcache_interop.rs
+++ b/packages/cultcache-rs/examples/cultcache_interop.rs
@@ -74,7 +74,7 @@ fn read_note(file: &str) -> Result<()> {
fn build_cache(file: &str) -> Result {
let mut cache = CultCache::new();
cache.register_entry_type::()?;
- cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file));
+ cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
Ok(cache)
}
diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs
index b85b763a..ed9cae32 100644
--- a/packages/cultcache-rs/src/lib.rs
+++ b/packages/cultcache-rs/src/lib.rs
@@ -1,18 +1,22 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
+use anyhow::bail;
+use anyhow::ensure;
use serde::Serialize;
use serde::de::DeserializeOwned;
+use std::any::Any;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
-use std::ffi::OsStr;
-use std::fmt;
use std::fs;
use std::fs::File;
use std::fs::OpenOptions;
-use std::io::Write;
+use std::marker::PhantomData;
use std::path::Path;
use std::path::PathBuf;
+use std::sync::Arc;
+
+use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
extern crate self as cultcache_rs;
@@ -27,6 +31,83 @@ pub trait CultCacheRegistry {
fn register_entries(&self, cache: &mut CultCache) -> Result<()>;
}
+pub trait SoaDocument: DatabaseEntry {
+ fn soa_columns(rows: &[Self]) -> BTreeMap<&'static str, CultSoaColumnValues>;
+}
+
+pub struct CultSoaColumnValues {
+ type_name: &'static str,
+ values: Box,
+}
+
+impl CultSoaColumnValues {
+ pub fn new(values: Vec) -> Self {
+ Self {
+ type_name: std::any::type_name::(),
+ values: Box::new(values),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct CultSoaColumn {
+ name: String,
+ values: Vec,
+}
+
+impl CultSoaColumn {
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+
+ pub fn values(&self) -> &[TValue] {
+ &self.values
+ }
+}
+
+pub struct CultSoaTable {
+ keys: Vec,
+ documents: Vec,
+ columns: BTreeMap,
+ _document: PhantomData,
+}
+
+impl CultSoaTable {
+ pub fn keys(&self) -> &[String] {
+ &self.keys
+ }
+
+ pub fn documents(&self) -> &[TDocument] {
+ &self.documents
+ }
+
+ pub fn len(&self) -> usize {
+ self.keys.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.keys.is_empty()
+ }
+
+ pub fn column(&self, name: &str) -> Result> {
+ let Some(column) = self.columns.get(name) else {
+ return Err(anyhow!("SoA table has no column {name:?}"));
+ };
+ let Some(values) = column.values.downcast_ref::>() else {
+ return Err(anyhow!(
+ "SoA table column {:?} stores {}, not {}",
+ name,
+ column.type_name,
+ std::any::type_name::()
+ ));
+ };
+ Ok(CultSoaColumn {
+ name: name.to_string(),
+ values: values.clone(),
+ })
+ }
+}
+
#[macro_export]
macro_rules! cultcache_registry {
($name:ident { $($entry:ty),* $(,)? }) => {
@@ -44,6 +125,22 @@ macro_rules! cultcache_registry {
};
}
+#[macro_export]
+macro_rules! cultcache_soa {
+ ($entry:ty { $($column:literal => $extractor:expr),* $(,)? }) => {
+ impl $crate::SoaDocument for $entry {
+ fn soa_columns(rows: &[Self]) -> ::std::collections::BTreeMap<&'static str, $crate::CultSoaColumnValues> {
+ let mut columns = ::std::collections::BTreeMap::new();
+ $(
+ let values = rows.iter().map($extractor).collect::>();
+ columns.insert($column, $crate::CultSoaColumnValues::new(values));
+ )*
+ columns
+ }
+ }
+ };
+}
+
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CultCacheEnvelope {
@@ -57,6 +154,93 @@ pub struct CultCacheEnvelope {
pub schema_id: Option,
}
+#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CultCacheSoaTable {
+ pub keys: Vec,
+ pub types: Vec,
+ pub payloads: Vec>,
+ pub stored_ats: Vec,
+ pub schema_ids: Vec