Compare commits

..
663 changed files with 5873 additions and 113607 deletions

View file

@ -1,121 +1,87 @@
name: CultMesh portability and performance
# The forge runs one job at a time and this workflow holds the slot 14 to 30 minutes, so it runs once per change:
# on the pull request (never again on the push that merges it; a break that merges from a stale base waits for the
# schedule), skipped for design-note-only changes, and on a
# daily schedule so main is still exercised when nobody opens a pull request.
on:
push:
branches:
- main
- "codex/**"
pull_request:
paths-ignore:
# Not `**/*.md` or `docs/**`: docs/start/README.md is read by the first-call tests of every runtime.
- "docs/*.md"
- "research/**"
- ".voidbot/**"
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
concurrency:
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
# workflow_dispatch runs are never cancelled: a tag run carries a release.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
jobs:
reactive-and-resource-lifetime:
name: ${{ matrix.os }} reactive semantics and leases
getting-started:
name: ${{ matrix.os }} one-command multiplayer onboarding
runs-on: ${{ matrix.os }}
# The job dies at the lower of this value and the forge runner's own cap, which Eureka is raising from 30 to 60 minutes
# (run 1378 carried 60 and was still cancelled at 30:02); the two must both say 60 before a full run can finish.
timeout-minutes: 60
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# The forge runner (label `ubuntu-latest`) is Linux-only and no Windows runner exists: the Windows leg runs on workflow_dispatch only (GitHub or a Windows runner), the ubuntu-latest leg gates push and PR. Windows push/PR triggers return in the PR that registers a Windows runner.
os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }}
os: [windows-latest, ubuntu-latest]
steps:
- name: Check out CultLib
uses: actions/checkout@v4
with:
# The declared-dependency-range test builds the admitted releases from their tags.
fetch-depth: 0
fetch-tags: true
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
# The forge runner image (catthehacker/ubuntu:act-latest) carries Node 24 and Python 3.12 on PATH, and these two
# setup actions downloaded them again from github.com anyway (about 2.7 and 2.8 minutes, run 639) because the
# image's tool cache is not the one they read. They run only where the image does not carry the tools (the
# workflow_dispatch Windows leg); the check below fails a Linux image that stops carrying them.
- name: Set up Node.js
if: runner.os != 'Linux'
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Set up Python
if: runner.os != 'Linux'
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check the image carries Node 24 and Python 3.12
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
node --version | grep -E '^v24\.'
python --version | grep -E '^Python 3\.12\.'
# The image's Python is distro-managed (PEP 668): pip refuses to install into it, so the Linux leg installs the
# CultLib packages into a venv under RUNNER_TEMP that later steps find first on PATH.
- name: Create the Python environment
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
python -m venv "$RUNNER_TEMP/venv"
echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH"
- 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
# The forge runner images carry no cargo either (the cross-runtime store tests build the Rust interop example); GitHub's hosted images do.
- name: Install Rust toolchain
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
if ! command -v cargo >/dev/null && [ ! -x "$HOME/.cargo/bin/cargo" ]; then
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
fi
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The forge runner images carry no pwsh; GitHub's Windows and hosted images do.
- name: Install PowerShell
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
if command -v pwsh >/dev/null; then pwsh -Version; exit 0; fi
SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo"
PWSH_VERSION=7.4.6
curl -fsSL "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell-${PWSH_VERSION}-linux-x64.tar.gz" -o "${RUNNER_TEMP:-/tmp}/powershell.tar.gz"
$SUDO mkdir -p /opt/microsoft/powershell/7
$SUDO tar -xzf "${RUNNER_TEMP:-/tmp}/powershell.tar.gz" -C /opt/microsoft/powershell/7
$SUDO chmod +x /opt/microsoft/powershell/7/pwsh
$SUDO ln -sf /opt/microsoft/powershell/7/pwsh /usr/local/bin/pwsh
echo "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1" >> "$GITHUB_ENV"
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 pwsh -Version
- name: Verify explicit reactive scheduling across runtimes
shell: pwsh
run: ./scripts/verify-reactive-document-scaling.ps1
@ -131,8 +97,8 @@ jobs:
--logger "console;verbosity=minimal"
# The TypeScript side of the CultMesh authority rules. This job is the
# only one that runs on Linux for main, codex/** and pull requests (and on
# Windows too under workflow_dispatch): cultnet-interop.yml is windows-latest and dispatch-only, so
# only one that runs on both Windows and Linux for main, codex/** and
# pull requests: cultnet-interop.yml is windows-latest and main-only, so
# until these steps existed the shared verifier's tests never ran on
# Linux at all, and the browser client's never ran in CI on any OS.
# packages/cultnet-ts verifies both vector files (content.test.ts judges both
@ -148,26 +114,6 @@ jobs:
- name: Verify the CultCache document owner with the TypeScript cache
run: npm run test --workspace packages/cultcache-ts
# The Python cache's unit suite ran only in cultnet-interop.yml (windows-latest, dispatch-only), so on Linux
# it ran nowhere. It sits after the TypeScript cache's test so the built cultcache-ts store holder exists for the
# cross-runtime store-lock test, which skips without it.
- name: Verify the CultCache document owner with the Python cache
run: python -m unittest discover -s packages/cultcache-py/tests
# cargo test for packages/cultcache-rs ran in no workflow: the Rust cache's own unit and doc tests were unwatched
# (only the interop example built, inside the C# store tests). The target directory is under RUNNER_TEMP, away from the checkout.
- name: Verify the CultCache document owner with the Rust cache
shell: bash
run: CARGO_TARGET_DIR="$RUNNER_TEMP/cultcache-rs-target" cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml
# tests/GameCult.Caching.Tests, the C# reference cache's own suite (about 1,260 tests, under a minute), ran in no
# forge workflow; only the Mesh tests above exercised the reference.
# The forge runner is globalization-invariant, so ADeterministicIdIsTheSameUnderEveryCulture... cannot construct a
# culture and throws CultureNotFoundException; it is excluded here and still runs wherever ICU is present
# (variants:follow_up:culture-test-invariant-image).
- name: Verify the CultCache document owner with the C# reference
run: dotnet test tests/GameCult.Caching.Tests/GameCult.Caching.Tests.csproj --filter "FullyQualifiedName!~ADeterministicIdIsTheSameUnderEveryCulture" --logger "console;verbosity=minimal"
# Packs the four TypeScript packages and installs them into a clean consumer, so a package that builds but does
# not install or load fails here.
- name: Verify the packed TypeScript packages install and load from a clean consumer

View file

@ -1,9 +1,10 @@
name: CultNet cross-runtime interop
# workflow_dispatch only: this job is Windows-only (pwsh Kotlin build, setup steps), and the forge
# runner is Ubuntu-only (no Windows runner exists; push and pull_request return in the PR that registers one), so a push or pull_request trigger would queue a job that can never run.
# Run it from GitHub or any Windows runner.
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
@ -29,12 +30,6 @@ jobs:
steps:
- name: Check out CultLib
uses: actions/checkout@v4
with:
# packages/cultnet-py/tests/test_declared_dependency_ranges.py reads the cultcache-py
# release tags and their file contents with `git show`; a shallow checkout has no tags
# and the test skips itself.
fetch-depth: 0
fetch-tags: true
- name: Set up .NET
uses: actions/setup-dotnet@v4
@ -135,10 +130,10 @@ jobs:
python -m unittest discover -s packages/cultmesh-py/tests
- name: Verify Rust RUDP reliability
run: cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml rudp
run: cargo test --manifest-path packages/cultnet-rs/Cargo.toml rudp
- name: Verify Rust CultMesh runtime-local surface
run: cargo test --locked --manifest-path packages/cultmesh-rs/Cargo.toml
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"

View file

@ -8,26 +8,15 @@ name: Publish packages
# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
# consume the core stack.
#
# Tag a Python release to publish, for example `cultnet-py-v0.1.0`. The tag's
# version must match the version in the package manifest;
# scripts/release-after-suite.mjs refuses the release otherwise rather than
# shipping a mislabelled artifact.
#
# npm publishing of @gamecult/cultcache-ts is deliberately off: `cultcache-ts-v*`
# is not a tag trigger until it is ruled on (the cultcache-ts job below runs from
# workflow_dispatch only, which publishes nothing). A tag run uses the workflow
# file at the tagged commit, not the one on main, so a tag on an older commit
# (`cultcache-ts-v0.15.0` sits on 7d292085, before the guard) still runs that
# commit's triggers and jobs. Read this file at the tagged sha before pushing a tag.
# 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; scripts/release-after-suite.mjs refuses the release otherwise rather
# than shipping a mislabelled artifact.
on:
push:
# cultcache-ts-v* is deliberately not a trigger: publishing @gamecult/cultcache-ts is disabled
# until it is ruled on, and whether NPM_TOKEN is set is unknown, so a pushed tag must not reach
# `npm publish`. The cultcache-ts jobs below still run from workflow_dispatch, which publishes nothing.
# (The comment sits above `tags:` because the tag-prefix pin in scripts/check-changelog-semver.test.mjs
# reads the list as `tags:` followed directly by its items.)
tags:
- "cultcache-ts-v*"
- "cultcache-py-v*"
- "cultnet-py-v*"
- "cultmesh-py-v*"

View file

@ -9,29 +9,15 @@ name: CultMesh QUIC native
# from the pinned debian:13 container below, never from a local or Yggdrasil
# build.
# Only a change to the committed binaries, their manifest or this workflow can change what verify-committed checks,
# so a branch push or pull request runs it for those paths alone; a tag always runs it.
on:
push:
branches:
- main
paths:
- "packages/cultmesh-ts/native/**"
- ".github/workflows/quic-native.yml"
tags:
- "cultmesh-quic-native-v*"
pull_request:
paths:
- "packages/cultmesh-ts/native/**"
- ".github/workflows/quic-native.yml"
workflow_dispatch:
concurrency:
# A newer push or PR update replaces the older run on the same ref. Tag pushes and
# workflow_dispatch runs are never cancelled: a tag run carries a release.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}
permissions:
contents: read
@ -63,12 +49,11 @@ jobs:
# workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds.
# A dry (workflow_dispatch) run uploads artifacts and creates no release.
# No Windows runner is registered on the forge, so this job is workflow_dispatch only
# (run it on GitHub or a Windows runner). The tag trigger returns in the PR that registers one.
# Until then a tag push skips the release job, which needs this build.
build-win32-x64:
name: Build win32-x64
if: github.event_name == 'workflow_dispatch'
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))
runs-on: windows-latest
timeout-minutes: 20
steps:
@ -107,8 +92,8 @@ jobs:
runs-on: ubuntu-latest
# Q6 A: this pinned Debian 13 container is the only permitted Linux
# release builder. The digest is the pin; the `debian:13` tag is only a
# label. The artifact links against this image's glibc (2.41);
# a bare runner's OS image has its own glibc (a GitHub ubuntu-latest image carried 2.39), which is not the deploy host's
# label. The artifact links against this image's glibc (2.41); a bare
# ubuntu-latest runner carries glibc 2.39, which is not the deploy host's
# OS (docs/typescript-quic-realtime-cut.md:2192-2197).
container:
image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1

4
.gitignore vendored
View file

@ -322,10 +322,6 @@ node_modules/
!packages/cultmesh-browser/**
!packages/cultmesh-kotlin/
!packages/cultmesh-kotlin/**
!packages/heimdall-delegation/
!packages/heimdall-delegation/**
!packages/heimdall-stake/
!packages/heimdall-stake/**
packages/**/node_modules/
packages/**/dist/
packages/**/dist-test/

View file

@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Networking.WebSock
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}"
@ -45,10 +49,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CultMath.Tests", "packages\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.ConsumerBuild", "tests\GameCult.Caching.ConsumerBuild\GameCult.Caching.ConsumerBuild.csproj", "{D3138F3C-9531-4906-A5D3-74BD857D0685}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.GeneratorConsumer", "tests\GameCult.Caching.GeneratorConsumer\GameCult.Caching.GeneratorConsumer.csproj", "{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GameCult.Caching.OutsideStoreTests", "tests\GameCult.Caching.OutsideStoreTests\GameCult.Caching.OutsideStoreTests.csproj", "{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -227,6 +227,18 @@ Global
{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
@ -263,30 +275,6 @@ Global
{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
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.ActiveCfg = Debug|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x64.Build.0 = Debug|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.ActiveCfg = Debug|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Debug|x86.Build.0 = Debug|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|Any CPU.Build.0 = Release|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.ActiveCfg = Release|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x64.Build.0 = Release|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.ActiveCfg = Release|Any CPU
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B}.Release|x86.Build.0 = Release|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x64.ActiveCfg = Debug|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x64.Build.0 = Debug|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x86.ActiveCfg = Debug|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Debug|x86.Build.0 = Debug|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|Any CPU.Build.0 = Release|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x64.ActiveCfg = Release|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x64.Build.0 = Release|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x86.ActiveCfg = Release|Any CPU
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -306,11 +294,10 @@ Global
{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}
{1A75EA8F-00B3-49AB-8398-B1B7723BE10B} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
{A54C2C29-941A-46A9-9DDE-9711B2F2EADD} = {39D316AB-ABB5-49A2-8E83-CAF4C75B48F4}
EndGlobalSection
EndGlobal

View file

@ -1,7 +1,5 @@
# CultLib
**New here? Start at [`docs/start`](docs/start/README.md)**: a language x package matrix with tested first-call snippets.
CultLib is GameCult's shared state, transport, and runtime substrate. It started
as reusable C# game infrastructure, but the useful thing now is larger: typed
`.cc` persistence, schema-stable wire formats, ergonomic reliable-UDP
@ -98,8 +96,7 @@ start with the maintained
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); it
needs a sibling Eve checkout (`--eve-root`) and is not in `CultLib.sln`.
[`samples/eve-browser-network`](samples/eve-browser-network/README.md).
One documented command runs both layers:
```powershell
@ -212,8 +209,8 @@ identity-owned C# session after provider route replacement.
Rust package tests:
```powershell
cargo test --locked --manifest-path packages/cultcache-rs/Cargo.toml
cargo test --locked --manifest-path packages/cultnet-rs/Cargo.toml
cargo test --manifest-path packages/cultcache-rs/Cargo.toml
cargo test --manifest-path packages/cultnet-rs/Cargo.toml
```
Kotlin package build:

File diff suppressed because it is too large Load diff

View file

@ -168,22 +168,22 @@
{
"label": "manifest",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQgA=="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA=="
},
{
"label": "manifest_out_of_order",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOnMTMxMDcyMJXZQGUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzbOAAQAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazplMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2pjI2MjE0NJXZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAypzEwNDg1NzaV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiQgA=="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA=="
},
{
"label": "manifest_metadata_order",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw=="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw=="
},
{
"label": "manifest_empty_body",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NaEwkIA="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA"
}
]
}
}

View file

@ -138,22 +138,22 @@
{
"label": "manifest",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQgA=="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQgA=="
},
{
"label": "manifest_out_of_order",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOnMTMxMDcyMJXZQGUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzbOAAQAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazplMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2pjI2MjE0NJXZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAypzEwNDg1NzaV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiQgA=="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY5TZQDMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmUAzgAEAADZT21lc2g6Y2RuOmNodW5rOjMxYTFmOWRlYTAxNjk1NTEwOTJkMDVlOGJmNGE0NDYyMjhjOGMzZWI0YzliNzEzYzY2YWRjYjdmZDUzYzg5YmWU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOU2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NpTZQDI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDLOABAAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazoyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAylNlAYzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZs4ACAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmM3ZjFiZGU5ZTFlZmE3ZDEzODVkNTFiMDc3ZWE4N2IwYzM4NjBkMjhkYTQzZDVkNmYyMjFlODQ1YzRjMjM2MGaQgA=="
},
{
"label": "manifest_metadata_order",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaV2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJloTCV2UBlMzFiZGM2MzU3N2ZlNzZmNjQ0MTAxNGFhYWEyMjI2ZDlmZmNlMTFiNDRkMGQ1MTBjYWRlMTMyYjc5ZmQ3ZDc2zgAEAADOAAQAANlPbWVzaDpjZG46Y2h1bms6ZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3NqYyNjIxNDSV2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZqY1MjQyODiV2UBhYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjzgAMAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YWExNDQ0YmU0YjY0MDk4ZjVkZDg2M2I1YjBhYzU2MTQ4MGIzZmI1YzQxZmMwN2NmNTBhNjJlZGM5NDEyNGJjY6Y3ODY0MzKV2UAyODMxNzE1ODU5ZjAxZjNmOTYzZGQwZjExOTRkYmRiYjc2MDk5OTc0MjMwMTVkMzFmNjA3OTExZTFiZjg2NDAyzgAQAADOAAQAANlPbWVzaDpjZG46Y2h1bms6MjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMqcxMDQ4NTc2ldlANTk5M2FjNjA0MDdlODY2MDE4NzNiYzM4ZWU2ZjhkYjNlYTkxMmU5ODc2MWNiNTU4YWI5Nzk1ZGIzMDZiNDc0Y84AFAAAzRLf2U9tZXNoOmNkbjpjaHVuazo1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjpzEzMTA3MjCQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw=="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZjRhZTJhN2M5NWY0ZDk2NjA1OGY3NWMxMzE4NWFhMGFiNmE5MWZjNWFmYWQ4ODYyOTZlZjJhZWU3NTVhMmFmMc4AFBLfvGFwcGxpY2F0aW9uL3gtY3VsdG5ldC12ZWN0b3LZITIwMjYtMDktMjJUMDA6MDA6MDAuMDAwMDAwMCswMDowMJaU2UAzMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJlAM4ABAAA2U9tZXNoOmNkbjpjaHVuazozMWExZjlkZWEwMTY5NTUxMDkyZDA1ZThiZjRhNDQ2MjI4YzhjM2ViNGM5YjcxM2M2NmFkY2I3ZmQ1M2M4OWJllNlAZTMxYmRjNjM1NzdmZTc2ZjY0NDEwMTRhYWFhMjIyNmQ5ZmZjZTExYjQ0ZDBkNTEwY2FkZTEzMmI3OWZkN2Q3Ns4ABAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOmUzMWJkYzYzNTc3ZmU3NmY2NDQxMDE0YWFhYTIyMjZkOWZmY2UxMWI0NGQwZDUxMGNhZGUxMzJiNzlmZDdkNzaU2UBjN2YxYmRlOWUxZWZhN2QxMzg1ZDUxYjA3N2VhODdiMGMzODYwZDI4ZGE0M2Q1ZDZmMjIxZTg0NWM0YzIzNjBmzgAIAADOAAQAANlPbWVzaDpjZG46Y2h1bms6YzdmMWJkZTllMWVmYTdkMTM4NWQ1MWIwNzdlYTg3YjBjMzg2MGQyOGRhNDNkNWQ2ZjIyMWU4NDVjNGMyMzYwZpTZQGFhMTQ0NGJlNGI2NDA5OGY1ZGQ4NjNiNWIwYWM1NjE0ODBiM2ZiNWM0MWZjMDdjZjUwYTYyZWRjOTQxMjRiY2POAAwAAM4ABAAA2U9tZXNoOmNkbjpjaHVuazphYTE0NDRiZTRiNjQwOThmNWRkODYzYjViMGFjNTYxNDgwYjNmYjVjNDFmYzA3Y2Y1MGE2MmVkYzk0MTI0YmNjlNlAMjgzMTcxNTg1OWYwMWYzZjk2M2RkMGYxMTk0ZGJkYmI3NjA5OTk3NDIzMDE1ZDMxZjYwNzkxMWUxYmY4NjQwMs4AEAAAzgAEAADZT21lc2g6Y2RuOmNodW5rOjI4MzE3MTU4NTlmMDFmM2Y5NjNkZDBmMTE5NGRiZGJiNzYwOTk5NzQyMzAxNWQzMWY2MDc5MTFlMWJmODY0MDKU2UA1OTkzYWM2MDQwN2U4NjYwMTg3M2JjMzhlZTZmOGRiM2VhOTEyZTk4NzYxY2I1NThhYjk3OTVkYjMwNmI0NzRjzgAUAADNEt/ZT21lc2g6Y2RuOmNodW5rOjU5OTNhYzYwNDA3ZTg2NjAxODczYmMzOGVlNmY4ZGIzZWE5MTJlOTg3NjFjYjU1OGFiOTc5NWRiMzA2YjQ3NGOQg6R6ZXRhoTGlYWxwaGGhMqNtaWShMw=="
},
{
"label": "manifest_empty_body",
"kind": "manifest",
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZXZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NaEwkIA="
"messagePackBase64": "mrVldXJla2EvY29udGVudC12ZWN0b3KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQC8YXBwbGljYXRpb24veC1jdWx0bmV0LXZlY3RvctkhMjAyNi0wOS0yMlQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTUAANlPbWVzaDpjZG46Y2h1bms6ZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NZCA"
}
]
}
}

View file

@ -35,7 +35,7 @@
}
},
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:cf8a453a36981321977355448d24df49bcefee22be6e3730b9140e87c29013f9",
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBjZjhhNDUzYTM2OTgxMzIxOTc3MzU1NDQ4ZDI0ZGY0OWJjZWZlZTIyYmU2ZTM3MzBiOTE0MGU4N2MyOTAxM2Y5zgAL3jG/YXBwbGljYXRpb24veC1jdWx0bmV0LXRzLXZlY3RvctkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwlpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDgAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDihMJXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAIAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjEzMTA3MpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAQAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjI2MjE0NJXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAYAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjM5MzIxNpXZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAgAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4pjUyNDI4OJXZQGEwYmI2OWY0NDg2MWViM2RiNTYwMjZhMWFiYmY5MWZmYTYyMTMyNTA4ZDVhMDZlMGQwZGRjNWNjMjZiZTMzZmPOAAoAAM4AAd4x2U9tZXNoOmNkbjpjaHVuazphMGJiNjlmNDQ4NjFlYjNkYjU2MDI2YTFhYmJmOTFmZmE2MjEzMjUwOGQ1YTA2ZTBkMGRkYzVjYzI2YmUzM2ZjpjY1NTM2MJKhYaFig6IxMKF4pWFscGhhoXmhMqF6"
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBjZjhhNDUzYTM2OTgxMzIxOTc3MzU1NDQ4ZDI0ZGY0OWJjZWZlZTIyYmU2ZTM3MzBiOTE0MGU4N2MyOTAxM2Y5zgAL3jG/YXBwbGljYXRpb24veC1jdWx0bmV0LXRzLXZlY3RvctkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwlpTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDgAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgACAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQDM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDjOAAQAAM4AAgAA2U9tZXNoOmNkbjpjaHVuazozNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4lNlAMzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OM4ABgAAzgACAADZT21lc2g6Y2RuOmNodW5rOjM3N2M3YjBjYzIyOWY0NzA1NjcwZDYzZDU0NGMyNzViNjYzZGY2ZThjODMxYjA5ODQ2YTVjNDhjY2EwOGI4NDiU2UAzNzdjN2IwY2MyMjlmNDcwNTY3MGQ2M2Q1NDRjMjc1YjY2M2RmNmU4YzgzMWIwOTg0NmE1YzQ4Y2NhMDhiODQ4zgAIAADOAAIAANlPbWVzaDpjZG46Y2h1bms6Mzc3YzdiMGNjMjI5ZjQ3MDU2NzBkNjNkNTQ0YzI3NWI2NjNkZjZlOGM4MzFiMDk4NDZhNWM0OGNjYTA4Yjg0OJTZQGEwYmI2OWY0NDg2MWViM2RiNTYwMjZhMWFiYmY5MWZmYTYyMTMyNTA4ZDVhMDZlMGQwZGRjNWNjMjZiZTMzZmPOAAoAAM4AAd4x2U9tZXNoOmNkbjpjaHVuazphMGJiNjlmNDQ4NjFlYjNkYjU2MDI2YTFhYmJmOTFmZmE2MjEzMjUwOGQ1YTA2ZTBkMGRkYzVjYzI2YmUzM2ZjkqFhoWKDojEwoXilYWxwaGGheaEyoXo="
},
{
"label": "ts empty body",
@ -72,7 +72,7 @@
}
},
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AL9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAA2U9tZXNoOmNkbjpjaHVuazplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1oTCSoWGhYoOiMTCheKVhbHBoYaF5oTKheg=="
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSg2UBlM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1AL9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAA2U9tZXNoOmNkbjpjaHVuazplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1kqFhoWKDojEwoXilYWxwaGGheaEyoXo="
},
{
"label": "ts single chunk, versioned",
@ -93,7 +93,7 @@
}
},
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:v1-beta:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532",
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSnVjEgQmV0YdlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRldlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMgDNA+jZT21lc2g6Y2RuOmNodW5rOjg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzKhMJCA"
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOlYXNzZXSnVjEgQmV0YdlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L9hcHBsaWNhdGlvbi94LWN1bHRuZXQtdHMtdmVjdG9y2SEyMDI2LTEwLTE5VDAwOjAwOjAwLjAwMDAwMDArMDA6MDCRlNlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMgDNA+jZT21lc2g6Y2RuOmNodW5rOjg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzKQgA=="
},
{
"label": "ts whitespace version, padded kind and mime",
@ -114,7 +114,7 @@
}
},
"recordKey": "mesh:cdn:artifact:pixel-heroes-hüt:unversioned:89f4ff56a25dd1db06a4ce6033603775d705fb96f30f8693733fef602a1ca532",
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOnIGJ1aWxkIKIgINlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L0gIHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTggINkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZXZQDg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzIAzQPo2U9tZXNoOmNkbjpjaHVuazo4OWY0ZmY1NmEyNWRkMWRiMDZhNGNlNjAzMzYwMzc3NWQ3MDVmYjk2ZjMwZjg2OTM3MzNmZWY2MDJhMWNhNTMyoTCQgA=="
"messagePackBase64": "mrVQaXhlbCBIZXJvZXMvSMO8dCDimIOnIGJ1aWxkIKIgINlAODlmNGZmNTZhMjVkZDFkYjA2YTRjZTYwMzM2MDM3NzVkNzA1ZmI5NmYzMGY4NjkzNzMzZmVmNjAyYTFjYTUzMs0D6L0gIHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTggINkhMjAyNi0xMC0xOVQwMDowMDowMC4wMDAwMDAwKzAwOjAwkZTZQDg5ZjRmZjU2YTI1ZGQxZGIwNmE0Y2U2MDMzNjAzNzc1ZDcwNWZiOTZmMzBmODY5MzczM2ZlZjYwMmExY2E1MzIAzQPo2U9tZXNoOmNkbjpjaHVuazo4OWY0ZmY1NmEyNWRkMWRiMDZhNGNlNjAzMzYwMzc3NWQ3MDVmYjk2ZjMwZjg2OTM3MzNmZWY2MDJhMWNhNTMykIA="
}
]
}

View file

@ -111,13 +111,8 @@ Not implemented yet:
not guess. Adding a code is therefore a coordinated change across every runtime, made together: the schema (both
copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and a captured-bytes case in
`packages/cultnet-rs/tests/error_message.rs` for it. TypeScript reads the schema. The current codes are
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`,
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
`limit_reached` (a receipt-capable server's per-session remembered-write table is full, so a new write is refused; `details` all null) and
`message_invalid` (a request that lacks a required field or carries an ill-typed one: a document-put or delete, or a subscription without an id; `details.field` names the request field, or `message` when the whole frame cannot be read).
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported` and
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns).
## Live Invariants
@ -127,9 +122,6 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe
- One primary owns writes for a shard until a stronger policy exists.
- Non-owner writes are rejected or explicitly forwarded. They are not silently
applied.
- A peer's put or delete is applied or forwarded only when the server's
`RemoteWriteAuthority` grants it; none configured refuses every remote write.
The connection key admits a peer to the transport and is never a grant.
- Raw snapshot/put/delete messages must pass through `CultNetDatabase`, not
bypass shard policy.
- The accepted shard log is stored in replica catch-up wire form. Object logs
@ -140,15 +132,6 @@ A malformed put or delete is `message_invalid`, not a per-operation code: the pe
- A shard snapshot represents a log sequence. Applying it replaces the local
shard view and advances the replica cursor to that sequence.
- Realtime subscriptions publish domain changes, not storage envelopes.
- A subscription's `database_change_raw` messages may carry `commitId` and `commitCount`: the changes of one cache
commit that this subscription delivers (`commitCount` of them, at least two). Both fields are optional and additive; a
peer that does not know them ignores them and applies each change alone. A replica that knows them applies the
commit's changes as one commit, so the replica judges the result (a swap of two unique-index values) and not the state
between records. A replica whose cache refuses a commit is faulted, never silent. `commitId` is opaque and valid only
within the subscription. The C# subscription server and database server tag commits; other runtimes adopt next.
- The C# writer sets every key of a `database_change_raw` message, nil where unset: a removed change carries `document`
nil, an added or updated one `recordKey` and `schemaId` nil. The schema accepts those nils; an added or updated change
still needs a document, and a removed one a `recordKey` and `schemaId`.
- Client-owned input documents may be predicted locally only inside explicit
`CultNetClientAuthorityScope` declarations.
- Predicted input state is not authoritative shared state. The shard log still

View file

@ -10,29 +10,15 @@
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"changeKind": { "enum": ["added", "updated", "removed"] },
"document": {
"oneOf": [{ "type": "null" }, { "$ref": "./cultnet.raw-document-record.schema.json" }]
},
"recordKey": { "type": ["string", "null"], "minLength": 1 },
"schemaId": { "type": ["string", "null"], "minLength": 1 },
"commitId": { "type": ["string", "null"], "minLength": 1 },
"commitCount": { "type": ["integer", "null"], "minimum": 2 }
"document": { "$ref": "./cultnet.raw-document-record.schema.json" },
"recordKey": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 }
},
"oneOf": [
"allOf": [
{
"properties": {
"changeKind": { "const": "removed" },
"recordKey": { "type": "string" },
"schemaId": { "type": "string" }
},
"required": ["recordKey", "schemaId"]
},
{
"properties": {
"changeKind": { "enum": ["added", "updated"] },
"document": { "$ref": "./cultnet.raw-document-record.schema.json" }
},
"required": ["document"]
"if": { "properties": { "changeKind": { "const": "removed" } } },
"then": { "required": ["recordKey", "schemaId"] },
"else": { "required": ["document"] }
}
]
}

View file

@ -28,7 +28,7 @@
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
"type": ["string", "null"],
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema"]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
@ -36,8 +36,8 @@
"additionalProperties": false,
"required": ["field", "value", "asOf", "current"],
"properties": {
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\".", "type": ["string", "null"] },
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns.", "type": ["string", "null"] },
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
}

View file

@ -59,7 +59,6 @@
],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"role": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 },
"schemaVersion": { "type": "string", "minLength": 1 },
@ -75,7 +74,6 @@
],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"role": { "type": "string", "minLength": 1 },
"uri": { "type": "string", "minLength": 1 },
"mediaType": { "type": "string", "minLength": 1 },
@ -94,7 +92,6 @@
],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"stage": { "type": "string", "minLength": 1 },
"startedAt": { "type": "string", "minLength": 1 },
"completedAt": { "type": "string", "minLength": 1 },

File diff suppressed because one or more lines are too long

View file

@ -1,206 +0,0 @@
# CultNet fork-desk contract v0 (specification)
Status: specification, drafted for Heimdall (Bifrost receiver), Huginn (the second door) and Void (Ratatoskr desk). No runtime implements it yet.
Rule source: `/opt/commons/handoff/fork-desk-answer-binding-20261110.md` (Heimdall; sections 1.1 to 1.5, 1.3a to 1.3d), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Where this page differs from that file it says so under "Differences from the binding file".
Format source: the fork desk artifact in `/opt/commons/handoff/fork-desk-original/` (ruling `eureka-substrate:ruling:fork-desk-nibu-format`).
Files here: `fork-card.schema.json` (one schema, a `$defs` entry per document), `fork-card-vectors.json`. Conformance: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript), both generated against vectors written by `scripts/gen-governance-vectors.py`. Each suite computes every hash with its own code and replays every scenario through a reference judge written from this page.
All documents are CultNet map-mode: every key written, nil where absent, closed schemas. Field names are snake_case as in the binding file and the mind's records; `schemaVersion` keeps CultNet's spelling (open choice 1). The bytes of every document are pinned in the vectors as `messagePackHex`.
## Documents
| schema id | what | who writes it |
|---|---|---|
| `gamecult.fork.card.v0` | the fork as shown | the publisher (a Mind's card projection) |
| `gamecult.fork.answer_request.v0` | the answer a decider sends | the client; Bifrost forwards it unchanged to Huginn's second door |
| `gamecult.desk_answer.v0` | the stored answer with one tagged outcome | Huginn only |
| `gamecult.fork.answer_result.v0` | what the sender gets back for a stored answer | Bifrost, from the stored `desk_answer` |
| `gamecult.fork.refusal.v0` | a body refused at the door, nothing stored (but `fork_too_large` on a stale answer, which sits over a stored `Refused(stale_fork)`) | Bifrost |
| `gamecult.fork.record.v0` | a fork's answers plus the derived counted answer | a projection of the stored `desk_answer`s |
| `gamecult.fork.ruling_cite.v0` | the citation a ruling carries | the Mind admitting the ruling |
| `gamecult.fork.list_request.v0` | list a Mind's forks (Transport) | the desk |
| `gamecult.fork.list.v0` | a page of entries, counts and a stamp | Bifrost, projected from Huginn |
| `gamecult.fork.read_request.v0` | read one fork (Transport) | the desk |
| `gamecult.fork.entry.v0` | one card with its record | Bifrost, projected from Huginn |
| `gamecult.fork.unavailable.v0` | the outcome is not confirmed (the second door or Huginn did not answer); retry with the same `request_id` | Bifrost |
### card
`{schemaVersion, fork_id, title, context, options[{id, text}], recommendation, depends[], leans, flag, raised_in, raised_at, deciders[], decider_count, status, ruling, ruled_from[], order, options_hash}`.
- `fork_id`: the mind's `question` key, `<campaign>:question:<label>`.
- **Hashed (the shown members):** `title`, `context`, `options` (each `{id, text}`, in shown order), `recommendation` (an option id, **never nil**: the stored `question` requires `recommended`), `depends` (strings, each one of the stored question's `depends` lines verbatim, at most 8, each at most 1000 UTF-8 bytes (the stored `Line`), in shown order, empty when none; the stored kind holds prose lines, so a line may read like a reference such as `campaign:kind:label` but is hashed as the text it is).
- **Not hashed:** `fork_id` (bound beside the hash), `leans` (an option id or nil), `flag`, `raised_in`, `raised_at` (the instant the question was first admitted, an RFC3339 instant string as `answered_at`, or nil when the mind holds no admission time, as for a card projected before the field; the key is always written; it never moves when the question is edited; age is the client's: now minus `raised_at`), `deciders`, `decider_count` (the number of deciders of the call, set beside `deciders`; neither it nor the emptiness of `deciders` enters the hash, so the hash a non-decider recomputes is a decider's), `status` (`open` or `closed`), `ruling` (the ruling id when a ruling closed the fork; nil when the card is open, and nil on a closed card whose question was withdrawn or otherwise resolved with no ruling), `ruled_from` (answer ids a ruling has been admitted from; non-empty only on a closed card), `order`, and `options_hash` itself.
- **`deciders` is read-only data.** The list is set outside the Mind, through Bifrost's admin path, and **Bifrost sends it per call**: Huginn keeps no copy (M5). The card projection copies the list the call carried. **The per-call list holds one to 64 ids** (Huginn's bound; vectors `deciders_at_the_cap_of_64`, `deciders_65`); the per-call list has no schema here, so the bound is stated for the cut that builds it. **`decider_count` is the size of that list, an integer 1 to 64, always written** (vectors `decider_count_missing`, `_zero`, `_65`, `_string`, `_null`). A fork with no decider is still refused, now as `decider_count` 0. **`deciders` holds that list for a decider and is empty for a reader who is not one: empty means "the ids are withheld from this reader", never "no deciders"** (vector `withheld_deciders`). Rule R-card: when `deciders` is non-empty it holds exactly `decider_count` ids; a visible list is whole (vectors `decider_count_fewer_than_the_ids`, `decider_count_more_than_the_ids_shown`). A question's own `audience` may only narrow it. A Mind never writes it, so a Mind cannot add itself and answer its own fork.
- Rules a schema cannot say, each with a negative vector: a `depends` line is at most 1000 UTF-8 bytes (the schema bounds 8 lines and 1000 characters; bytes need the rule); option ids are unique; `order` is unique among a Mind's cards (a list with two cards on one `order` is refused, naming both fork ids); `recommendation` and `leans` name an option; deciders are unique; an open card names no `ruling` and its `ruled_from` is empty (a ruling that answers a fork closes it); `ruled_from` is non-empty only when `ruling` is set; a closed card may name no ruling (the question was withdrawn, superseded or deferred: step 6 reads `status`, not the ruling); `options_hash` equals the hash of the shown members; R-card (above); R-answer-result: `answer_result.answer.answerer` is never nil, being the sender's own stamped account (vector `answer_result_with_a_nil_answerer`); R-record, R-entry and R-list ("Reader views" below).
**Reader views.** A card and its record are shown in one of two views. The **decider view** carries the ids: `deciders` whole, every `answerer` named, and the counts equal to the ids. The **non-decider view** withholds them: `deciders` empty, every `answerer` nil, the counts kept (`decider_count`; `answerer_count` on the record). Who is a decider is Huginn's decision, not this contract's: a reader is a decider exactly when the call's subject is byte-equal to one of the call's deciders (`eureka-substrate:ruling:desk-read-visibility-withheld-ids`); Huginn's door withholds, Bifrost cannot (the card is opaque to it). There is no viewer flag on any document: the view is read off whether `deciders` is empty. This contract carries both views and refuses a view that leaks: R-record: if every `answerer` is named, `answerer_count` is the number of distinct ids; if all are nil and there is at least one answer, `answerer_count` is between 1 and the number of answers; with no answers it is 0 (vectors `answerer_count_disagrees_with_the_ids`, `answerer_count_counts_answers_not_people`, `withheld_count_over_the_answers`, `withheld_count_zero_over_answers`, `withheld_count_over_no_answers`); R-entry, the leak rule: an entry with `deciders` non-empty names every answerer, an entry with `deciders` empty names none (vectors `decider_card_with_withheld_answers`, `withheld_card_with_named_answers`, `withheld_card_with_a_named_answerer_among_nils`); R-list: all entries of one page agree on the view (vector `a_page_mixes_views`). The view does not move the page: `non_decider_page` has the counts, order, cursor and stamp of the decider page it twins, and the vectors' no-leak check finds no id string in a non-decider vector's bytes. `options_hash` is the same in both views, and the bound-answer and `stale_fork` machinery is untouched.
### Options hash (canonical bytes)
`options_hash` = lowercase hex SHA-256 of the UTF-8 bytes of the following, byte for byte:
1. Build an object with exactly the five members `title`, `context`, `options`, `recommendation`, `depends`. Each option is an object with exactly `id` and `text`.
2. **NFC-normalise every string, object keys included.** No trimming, no newline rewriting, no case folding. A string with a lone surrogate is not a card (it has no UTF-8). Normalisation stability makes NFC stable across Unicode versions for already-assigned code points; a runtime's own normaliser is trusted and the vectors include a decomposed pair and astral code points (open choice 2).
3. Serialise as RFC 8785 (JCS): no whitespace; object keys sorted by UTF-16 code unit, which for these ASCII keys is `context, depends, options, recommendation, title` and, inside an option, `id, text`; array order is as shown (option order and `depends` order are part of the hash).
4. Strings are written in double quotes; only `"` and `\` are backslash-escaped, U+0008, U+0009, U+000A, U+000C, U+000D as `\b \t \n \f \r`, every other code point below U+0020 as `\u00xx` in lowercase hex; everything else is written raw as UTF-8 (U+007F, U+2028, `<`, `&` and astral code points included).
5. **Numbers and nulls:** the member set contains none. There is no number to canonicalise (a number anywhere in the five members is a defect) and `recommendation` is never nil, so no nil is hashed.
`fork_id` is not in the hash and is bound beside it in its own field, so one hash cannot be replayed under another fork. The publisher computes the hash and stores it in the card; the client recomputes it from what it rendered and refuses to send on a mismatch; the receiver recomputes from its stored fork, never from the client.
Vectors `optionsHash`: the seven of the binding file (`heimdall_A..G`), computed by the Python generator, by the C# suite and by the TypeScript suite independently, plus text, title, context and `depends`-order mutations, a case showing the unshown members do not move the hash, escapes and markup, and astral ids. Each suite also mutates every shown member, an option id included, and asserts the hash changes.
### answer_request
`{schemaVersion, fork_id, options_hash, answer, retracted, retracts, answered_at, request_id}`. **`request_id`** is set by the client, a lowercase D-format UUID, one per intended answer, kept across retries; the first stored answer that carries it binds it to the stamped `answerer`. "Same body" means every field equal, `answered_at` included, so a client resends identical bytes. A **retry with the same `request_id` and the same body returns the stored outcome unchanged** (a `Refused` one included; a replayed `stale_fork` carries the **current** card, because a stored `desk_answer` has no card field and no storage is added for one) and **never adds a Revision**; the same `request_id` with a different body is `request_id_conflict` (409, a refusal, nothing stored). The same id from another account is another request, judged on its own: Y reusing X's `request_id` with a body identical to X's gets **Y's own new answer, with its own answer id, stamped Y**; X's stored outcome (a `Refused` one included) is never returned in its place, and Y's request neither conflicts with it nor replays it. An `of` that names the counted answer is allowed, because answer ids are not secret between deciders (vectors V14: an off-list X's `Refused`, then a decider Y's identical body is its own `Counted`; X's `Counted`, then Y's identical body is its own `Revision` whose `of` names X's answer, and X's later retry still returns X's answer). Both suites assert it on every scenario: a response's `answerer` is the step's account, and an id first bound by another account never yields that account's answer id or document. **`retracted` is part of the body**: the same `request_id` with `retracted: true` and then `false` (or the reverse) and every other field equal is `request_id_conflict`, for the illegal forms as for any other (vectors `R1_retracted_true_then_false_...`, `R1_retracted_false_then_true_...`). Each illegal form's own retry, byte for byte, replays its stored `bad_retraction` (vectors `R1_a_retraction_with_an_answer_...`, `R1_a_retraction_with_no_target_...`, `R1_a_plain_answer_with_a_target_...`). A body refused at step 1 or 2 binds nothing, so its id is free to reuse; so a target-less retraction resent with `retracted: false` (no answer, no retraction) is `body_invalid` at step 2, not a conflict. The lookup comes after steps 1 and 2 and before the fork is looked up, so a retry gets its stored outcome even if the card has since changed. `answer` is `{choice, note}` (at least one non-nil) or nil on a retraction. `choice` is an option id; `note` is the person's words, verbatim, 1 to 4000 **UTF-8 bytes**, not blank. A schema counts characters, not bytes, so its `maxLength: 4000` (code points) is only the looser bound and both suites enforce the byte limit as a rule: 4000 ASCII bytes pass; 1000 astral characters (4000 bytes) pass and 1001 (4004) fail; `e` plus a combining acute is 3 bytes, so 1333 of them and an `x` pass and 1334 fail; 1999 two-byte characters plus three ASCII (4001 bytes) fail. A note over the limit is `body_invalid`. `answered_at` is the client's claim. **A request never carries `answerer` or `authority`**: both are stamped by Huginn (`answerer` from the verified subject the receiver passes; `authority` from the door's configuration). A body that names either key at any depth is refused whole, never stripped (`body_forbidden_field`); any other unknown key is `body_unknown_field`. The request type Bifrost sends over the second door is this document; it has no field for either key, so a client cannot supply them by construction, and Bifrost's scan is the first line, not the only one. How Huginn receives the verified subject is the door's authenticated context, not a body field (open choice 3). In map-mode `retracted` is always written (the binding file's optional `retracted` becomes `false`).
### desk_answer (the stored kind)
`{schemaVersion, answer_id, fork_id, outcome, of, retracts, code, options_hash, choice, note, answered_at, received_at, claim_in_future, answerer, authority, request_id}`, key `<campaign>:desk_answer:<question-label>.n<seq>`; `answer_id` is `n<seq>`, assigned by Huginn per question, and **an answer is named by its answer id** everywhere (`retracts`, `of`, `ruled_from`).
The stamped `authority` field of a stored answer is an **attribution**: the role the verified account holds (`Operator`, `Steward`, `Member`), copied from Bifrost's state. A stored answer confers no authority. Authority exists only where a ruling cites a counted answer ("Operator authority" below); the field keeps the binding file's spelling (open choice 8). One tagged `outcome`, each variant carrying only its own link:
- `Counted`: the answer that counts. `of`, `retracts`, `code` nil.
- `Revision`: a later passing answer, kept, not counted while the first stands. `of` names the counted answer at the time.
- `Retraction`: the stored form of `retracted: true` plus `retracts`. `retracts` names the answer retracted; `choice` and `note` nil. The illegal pair (a retraction with no target, or a target with no retraction) cannot be written.
- `Refused`: any refusal from steps 4 to 7 or R1 and R2, with its `code`, stored too. `choice` and `note` keep what the body carried, and so does `retracts`: a refused body that named a target (a retraction of any refusal code, or a plain answer carrying one) projects it as `n<seq>`, never nil; a refused body that named none has `retracts` nil. `of` is nil.
`received_at` is Huginn's admission time; "first" means first by admission order (the `n<seq>` ordinal), never by `answered_at`. `claim_in_future` is true when `answered_at` is more than five minutes after `received_at`: accepted but flagged. It is a function of the two stored values, so the writer compares against the `received_at` it stores, at the precision it stores it (a flag decided against an unrounded clock and a stored second-truncated `received_at` disagrees with the document it sits in). **Not stored, and no answer id:** a body refused at step 1 or 2 (Bifrost; it never reaches Huginn), and a body naming an unknown fork (step 3; Huginn answers it from its stored forks, but with no question there is nothing to key a record under). Each gets a `refusal` and is dropped. A malformed body is therefore never stored, and neither is an unknown fork. Every refusal from step 4 on is stored. **`unavailable`** (503) is not a refusal and not in the refusal set: Bifrost answers it when the second door or Huginn did not answer after the auth steps and the body shape passed (a bad body is still refused first). **It says the outcome is NOT CONFIRMED, never that nothing was stored**: a timeout after the request reached Huginn may have stored it. It carries no answer id. The desk retries the same request with the same `request_id` and learns the outcome: the stored one if Huginn had it, a fresh one if not. It covers **calls 1 and 2 as well as the answer POST** when Huginn cannot be reached (Transport; a read has no outcome to confirm, so a retry is plain). `retryAfterSeconds` is nil when Bifrost has no hint.
**The record.** `record` is `{schemaVersion, fork_id, options_hash, counted_answer_id, answerer_count, answers[]}`. `answerer_count` is an integer of at least 0: the number of distinct answerer ids over every stored answer of the fork, refusals included (a refusal stores the stamped answerer too), and it is the same number in both views (vectors `answerer_count_missing`, `_negative`, `_null`). `answers[].answerer` is a string or nil (nil only in the non-decider view).
**The mind's record and this document.** Huginn keeps the answer as its own record kind (`epiphany.pipeline.desk_answer.v2`, key `<campaign>:desk_answer:<question-label>.n<seq>`, the outcome as one externally tagged value, the fork as `fork`). The second door projects it to this document and nothing else crosses: `answer_id` is the key's `n<seq>` part, `fork_id` is the stored `fork`, the tagged outcome flattens into `outcome`, `of`, `retracts` and `code`, and the six refusal codes are spelt as in the schema (snake_case; the record's enum spells them in the mind's own PascalCase). On the wire an answer is named by its `n<seq>` alone, always inside the fork whose `fork_id` the document carries; a runtime that holds the full record key converts at the door, in one place. Huginn's stored `desk_answer` keeps a non-nil `answerer`; nil exists only in a record projected to a non-decider. `body_sha256` and any other digest Huginn keeps to decide "same body" stay in the record: Huginn alone decides a replay or a `request_id_conflict` and Bifrost never compares bodies, so no digest is a field of this document. A digest is a valid way to decide it only if it covers `fork_id`, `options_hash`, `answer.choice`, `answer.note`, `retracted`, `retracts` and `answered_at` (every field of the request but `request_id`), is computed by Huginn and never read from a client.
**Huginn's digest, pinned in the vectors (`bodyDigests`; informative, not a wire document).** SHA-256 of one compact JSON object as `serde_json::to_string` writes it (UTF-8, no spaces), lowercase hex, keys in this order, `null` for a missing value, strings verbatim: `fork`, `options_hash`, `answered_at`, `choice`, `note`, `retracts` (the **full stored key** `<campaign>:desk_answer:<label>.n<seq>`: the door converts the wire's `n<seq>` before the digest), then, **only for the three illegal forms** (R1: a retraction carrying an answer, a retraction naming no target, a plain answer carrying a target), `illegal_retraction: true` and `retracted: true|false`. `request_id` is not in it. A plain answer and a legal retraction carry neither trailing key, so their digests are the ones Huginn main pins (`8a4d5b2e`, `9f807538`) and do not move. `9f807538` is pinned for a target no door produces (`Q1.1`); the golden for the real legal retraction is the door's form, the wire's `n1` converted to `Q1.n1` (`84576840`, vector `legal_retraction_door_form`, also main-valid). Source: Eureka's `desk-body-digest-order-20261110.md`, computed with `sha256sum`; the generator, the C# suite and the TypeScript suite recompute every digest from the input fields. **Status:** the three `retracted` goldens (`935cab1a`, `82b24ccc`, `34ef86ed`, and `79eee623` for form (i) with a null target) are the form of `cut-desk-digest-retracted.r2`, merged as Huginn #30. Eureka reports that these four and the door-form legal retraction (`84576840`) reproduce byte for byte on Huginn main `f0ca504` (the #30 fix); that run is hers, reported to us, and was not rerun here. Before #30, Huginn main (`7ea11b4`) wrote `illegal_retraction` alone, so forms (i) and (iii) collided there (`69074cc6`, pinned as `illegal_i_and_iii_alike_on_main`). The wire contract does not wait on it: the conflict scenarios are pinned in `scenarios`.
### Counting rules (in order; the first failure refuses with that code)
| step | rule | refusal | stored |
|---|---|---|---|
| 1 | verified session or bearer | `unauthenticated` (401) | no |
| 2 | closed body; no `answerer`/`authority` at any depth; type-correct; not (no `answer` and not a retraction) | `body_forbidden_field`, `body_unknown_field`, `body_invalid` (400) | no |
| 3 | `fork_id` is a stored fork | `fork_unknown` (404) | no |
| 4 | `options_hash` equals the stored fork's hash | `stale_fork` (409) | **yes**; the result carries the current card |
| 5 | the stamped `answerer` is on `deciders` | `not_a_decider` (403) | yes |
| 6 | the fork is open (`status: open`) | `fork_closed` (409) | yes |
| 7 | `choice`, if present, is an option id | `choice_invalid` (400) | yes |
| 8 | first passing answer for `(fork_id, options_hash)` is `Counted`; later passing answers are `Revision`s naming the counted one | | yes |
| 9 | a Matrix edit, redaction or reaction changes nothing | | no event is an answer |
**Retraction** (`retracted: true`, `retracts: <answer id>`, `answer: nil`, `options_hash` still carried, so step 4 applies) passes steps 1 to 6, then:
- R1 form: it carries no `answer`, it names a `Counted` or `Revision` answer of this fork under this `options_hash`; else `bad_retraction` (400). A retraction naming an answer already retracted is `already_retracted` (409). `retracts` on a body that is not a retraction is `bad_retraction`.
- R2 who: the original answerer or any other decider; step 5 already requires a decider.
- R3 is dropped and its label stays vacant, so R4 keeps its name in the binding file. A ruling that answers a fork closes it (Eureka and Heimdall, 2026-11-10), so a ruling cannot cite an answer on an open fork (that is the stale-click gap, and a card with `ruled_from` set and `status: open` is invalid), and a retraction after a ruling meets step 6: refused `fork_closed`, stored. There is no `ruled` code (vector `V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed`).
- R4 counted answer: retracting the counted answer removes it from the count and the next passing answer in admission order counts again. Retracting an answer that was not counted changes nothing. After every answer is retracted, a fresh answer is `Counted`. Records stay append-only.
**The counted answer is derived**, not stored: the first `Counted` or `Revision` answer under the card's current `options_hash` that no `Retraction` names. When the card changes, answers under the old hash stop counting and the next answer under the new hash is `Counted` (vectors `a_changed_card_...`). The `record` projection carries it as `counted_answer_id`.
### Operator authority
A ruling may carry `authority: Operator` only if it cites (`ruling_cite.answer_id`) a stored `desk_answer` whose outcome is `Counted` and whose stamped attribution (the answer's `authority` field) is `Operator` (or a stamped relay of her words from a mapped door, which this contract does not model). The citation is what gives a ruling authority: an attribution on an answer is a fact about the account, and authority exists only where a ruling cites a counted answer. The cited answer id is what `ruled_from` lists on the closed card. Negative vectors (`rulingCites`): no citation, a `Revision`, a `Refused` answer, a Counted answer by a non-operator, an answer that does not exist. Other authorities need no citation.
## Transport
Source: the desk's client needs (Ratatoskr PR #24 section 12, `ratatoskr-desk-spec-20261110.md`) and Heimdall's binding file, `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, section 1.3c, which is the source for the second door. **Everything in this section is spec text for Bifrost to build; no route, scope or audience below exists in Bifrost today.** The routes (with the `{mind}` segment), the scope names `desk.read` and `desk.answer`, the audience `bifrost-desk` and the auth order are Heimdall's rulings (his file, sections 4.1 to 4.3a); this page writes them as documents and vectors.
**Authorities.** Cards are projected **read-only from Huginn**: Bifrost never authors a card, a ruling or a status, and the desk never writes one. Bifrost verifies the bearer, resolves the actor from its `sub` (a `UserAccount.Id`), and for an answer forwards a typed `DeskAnswer` on the peer-checked second door to Huginn, then returns **Huginn's outcome unchanged**: the stored `desk_answer` in an `answer_result`, or its refusal. The wire of the second door is **pending**: Eureka's cut spec follows her record PR, so this page defines the documents the desk sees and not the bytes between Bifrost and Huginn (open choice 3).
| call | route (proposal) | request | response | token |
|---|---|---|---|---|
| 1 list | `GET /api/desk/{mind}/forks?status=&cursor=&limit=` | `list_request`: `mind` from the path, the rest from the query string | `list` | audience `bifrost-desk`, scope `desk.read` |
| 2 read | `GET /api/desk/{mind}/forks/{fork_id}` (the id percent-encoded) | `read_request`, its `mind` and `fork_id` from the path | `entry`, or `refusal` `fork_unknown` (404) or `fork_too_large` (422) | `desk.read` |
| 3 answer | `POST /api/desk/{mind}/answers` | `answer_request` as the body; `fork_id` stays in the body, the Mind is the route's and is no body field | `answer_result` (200 for `Counted`, `Revision` and `Retraction`; a stored `Refused` answer keeps the `answer_result` body and takes its code's status from the counting-rules table: `stale_fork` 409, `not_a_decider` 403, `fork_closed` 409, `choice_invalid` 400, `bad_retraction` 400, `already_retracted` 409, as the vectors' `results` pin; a replay returns the same status as the first call; the desk reads `outcome` and `code` from the body), or a `refusal` (`fork_unknown`, or `fork_too_large` when a stale answer's card re-read is past the cap), or `unavailable` | audience `bifrost-desk`, scope `desk.answer` |
**The `{mind}` segment** (Heimdall's rulings M1 to M5; vectors `transport.mindRoutes`).
- **M1.** `{mind}` is looked up in a **fixed configured list** of Mind ids, each mapped in configuration to that Mind's socket on the peer-checked door. The segment is percent-decoded once and compared, ordinally, with the list's ids: no case folding, trimming or Unicode normalisation, and a decoding failure is a miss. It is **never joined into a path, a socket name or a query**, so a value off the list opens nothing (`../x`, `a/b`, `a%2fb`, `eureka%00`, a 4 KB string, `GAMECULT-CHAT` for `gamecult-chat`, a trailing space, bytes that are not UTF-8: all unknown, 404, no socket opened).
- **M2.** An unknown Mind gets **the same refusal as an unknown fork**: `fork_unknown`, 404, the same bytes (the document's `error` names neither the Mind nor the fork). The list of Minds is not probeable. Auth is judged first, so a caller without a valid token, scope or Active membership learns nothing about which Minds exist.
- **M3.** On the POST the route's Mind must hold `fork_id`: step 3 is judged against **that Mind's** stored forks, so a `fork_id` the route's Mind does not hold is `fork_unknown` and nothing is stored, in either Mind. A fork cannot be answered through another Mind's route. A body that names `mind` is `body_unknown_field`: the Mind is the route's.
- **M4.** Scopes are **not per Mind**: `desk.read` and `desk.answer` open the routes of every configured Mind; every reader sees every fork's card and outcome (a non-decider sees no account id, "Reader views"), and which forks a caller may answer is the decider list.
- **M5. The decider list is sent per call.** Bifrost holds the admin-set list and sends it with **every answer, read and list request** to Huginn, as a typed field on the same peer-checked door; Huginn keeps no copy of it. Because it travels with read and list too, a projected card's `deciders` is the list of that call for a decider and empty for a reader who is not one, with `decider_count` travelling with both views, and step 5 judges an answer against the list the same call carried. It comes only from Bifrost's admin-set state: never from a Mind and never from any request body a client controls: no HTTP field, header or query parameter on any desk route sets or extends it, and a body naming `deciders` is `body_unknown_field` (scenario `M5_...`). A request carrying it from a Mind's own door or session is refused. **This is Bifrost and Huginn work and no vector here can test it** (the typed field and the door's wire are pending, open choice 3); the rule is stated so that the cut that builds the request has it. (Heimdall's ruling, #minds 2026-11-10 19:26Z; it replaces the earlier reading of a list Huginn receives once and holds.) **Envelope names:** the desk's envelope on that door uses the names this page uses, `mind`, `authority`, `subject` and `deciders`, with no conversion at the door (Eureka, #minds 2026-11-10 19:58Z); `deciders` is a per-call list of short ids at the envelope's top level beside `subject`, never inside the request body, and Huginn keeps no copy.
Calls 1 and 2 are enough for a read-only desk. Every response is the pinned MessagePack document or its JSON form with the same keys (open choice 1 of `vote-api.md`: the media type is unsettled).
**Auth, in order, per route.** (1) A bearer, else `unauthenticated` (401). (2) The token's audience is `bifrost-desk`: **the Matrix Authentication Service token chat holds today is refused, and so is any `votes.*` token** (audience `bifrost-votes`), both as `unauthenticated`. (3) The scope the route needs, else `insufficient_scope` (403): `desk.read` for calls 1 and 2, `desk.answer` for call 3, and neither implies the other. (4) The account behind `sub` is an Active member, else `forbidden` (403), as in the vote API; scope is judged first. (5) For call 3 the body is then shaped (steps 1 and 2 above), so **a non-Active member who sends a bad body, including one naming `answerer` or `authority`, gets `forbidden`, not a body refusal**; a body naming either key at any depth is `body_forbidden_field` only for an Active member. (6) Only then is Huginn asked, so `unavailable` never hides an auth or body refusal. A `votes.*` token touches none of the three. Heimdall's file leaves the audience and the claims check open; `insufficient_scope`, `forbidden`, `request_invalid` (400, a list or read request that fails its document; an unknown key, `authority` included, is `request_invalid` here, whatever Huginn's own decode does with it) and `request_id_conflict` are added to the door refusals, and so is `fork_too_large` (422, list and read; see List and Read). **`request_id_conflict` is answer-only**: the idempotency key exists only on the answer. A door `request_id_conflict` on a read or list is a door fault and reaches the client as 503 `unavailable`, never as `request_id_conflict` (Persona, 2026-11-11, agreed with Heimdall; vectors `transport.doorFaults`, each carrying the client response's bytes). The `unavailable` text's "same request_id" retry hint is for answers; reads and lists carry no request_id..
**List.** `list_request`: `{mind, status, cursor, limit}`. `mind` is the route's `{mind}`, a Mind id from the configured list. `status` is `open`, `recorded` or nil for both; `recorded` means a closed card. Cards come **newest first by `order`**. `order` is the card's unique rank per Mind: an integer from 0 that never changes once the card exists, later questions only appending (Huginn derives it from the Mind's questions sorted by admission ordinal, then key). A Mind never holds two cards of one `order`; a list or card set that does is refused, and the refusal names both fork ids. 0 is legal, but no cursor names it (`c0` fails the cursor pattern): a page ending at order 1 returns `c1`, and the next page returns the order-0 card alone with `next_cursor` nil. `limit` is 0 to 50; **`limit` 0 returns no entries, only the counts and the stamp**, which is what "N open" on the Mind's header needs. `cursor` is nil or `c<N>`: the cards whose `order` is below N; a client treats it as opaque and sends back the `next_cursor` it was given. `list`: `{mind, stamp, open_count, recorded_count, forks[entry], next_cursor}`. The counts cover every card the viewer may see, whatever `status` and `cursor` asked for. `stamp` is `s<N>`, N the receiver's last-seen Huginn admission ordinal for the Mind's desk documents: the same stamp means the same state, so a desk polls `limit` 0 and fetches bodies only when the stamp moves. `next_cursor` is nil on the last page. **A page may hold fewer than `limit` entries** when the next entry would push the encoded response past the receiver's response budget (Huginn: 65536 bytes); it then holds at least one, `next_cursor` is `c<order of the last entry returned>` so the next page resumes below it, and the counts and the stamp are those of the full list, unchanged. **`fork_too_large`** (HTTP 422) refuses a page whose **first** entry alone is past the budget: 422 because the request is well formed and authorised but names a resource the list cannot carry, and 413 is for a request body that is too large, which this is not; retrying returns the same refusal. Its `error` names the fork id and its `order`, for example `fork_too_large: fork <fork_id> (order <n>) alone is past the response budget`. **A refusal's `error` text is informational and not pinned**: the contract pins the code, the HTTP status, that the text names the fork id (and the order on a list), and the `c<order>` continuation on a list; the vectors' wording is a sample, and the judges check those four things, not the bytes of the text. The client continues the list with cursor `c<n>`, the cards strictly below that order, which skips this fork; it is then readable only by id (the read route returns the whole entry, and refuses with the same code only when that entry alone is past the cap, see Read); `c<n>` is valid only when n is at least 1, so for order 0 there is nothing below and the client has reached the end. `limit` 0 returns no entries and never refuses. (Ruled by Persona, 2026-11-11.) An `entry` is `{card, record}`: the card and its `record` (the answers with their outcomes and the derived `counted_answer_id`).
**Read.** `read_request` is `{mind, fork_id}`; the response is the `entry` for one fork of that Mind. Its `card.options_hash` is **computed by the server** from the five shown members as in "Options hash" and the client recomputes it from what it rendered; a card whose hash is not the hash of its members is no card (negative vector `card_hash_wrong`). The record's `fork_id` and `options_hash` are the card's, and `counted_answer_id` is derived, not copied (negative vectors for each). Every `desk.read` holder sees every fork's card and outcome in a configured Mind; only a decider's notes depend on the caller, going to callers in the admin-set `deciders` list, which Bifrost enforces at the door (ruling `desk-read-visibility`, option (c)); `fork_unknown` is only for a fork id that does not exist under the route's Mind (an unknown Mind answers the same, M2), so no fork is ever hidden by it, and a caller without `desk.read` is refused by the auth order above (`insufficient_scope`) before any lookup. **Huginn withholds account ids from a reader who is not a decider and returns counts ("Reader views"); Bifrost still strips notes.** Every `desk.read` holder sees cards and outcomes; notes (a decider's own words) only go to callers in the admin-set `deciders` list. The read and list shapes are unchanged and carry no audience field (`eureka-substrate:question:desk-read-visibility`, Heimdall's option (c)). **A read is refused `fork_too_large` (422) when the whole entry is past the reply cap** (Huginn's daemon caps every reply at 65536 bytes; the cap, not the page budget, is what a read meets). Its `error` names the fork id and has no order and no cursor to carry, because there is nothing to continue with (sample: `fork_too_large: fork <fork_id> alone is past the response budget`) (vector `transport.readTooLarge`; the cap is in no vector). **The answer route meets the same cap:** when an answer is `stale_fork`, Bifrost re-reads the current card to attach it, and if that read is past the cap the answer is refused `fork_too_large` (422) with the read form of the text, the same document as a read. Huginn has already stored the answer as `Refused(stale_fork)`, as for any stale answer, and it is never counted; the client is told `fork_too_large` instead of receiving the current card, and a replay of the same `request_id` gets the same 422 while the fork stays oversize. It is never `unavailable` (vector `transport.answerTooLarge`). A stripped read serves a decider's `note` as `note: null`, never omitted (map-mode: every key is written); `desk_answer.note` is already nullable in the schema, so a served record validates as it stands.
**Answer.** The body is the `answer_request` and nothing else a client may set: `fork_id`, `options_hash`, `answer`, `answered_at`, `request_id` (plus `retracted` and `retracts`, written `false` and nil unless the call retracts). The response is the stored outcome: `Counted`, `Revision`, `Retraction`, or `Refused` with its code, and the **`counted_answer_id`** the fork has once this call is applied (`n<seq>` or nil; derived as in "The counted answer is derived", the same value the `record` projection carries, so the desk needs no second read). A replay returns the stored outcome with the `counted_answer_id` current at the retry, not the one it had when first stored. **A `stale_fork` refusal carries the current card** in `answer_result.card`, so the desk can show the new fork without another read. Nothing is stored for a step 1 or 2 refusal, an unknown fork (or an unknown Mind, or a fork another Mind holds) or a `request_id_conflict`. `unavailable` promises nothing either way.
**Vectors (`transport` in `fork-card-vectors.json`).** `listRequest`, `readRequest` and `entry` (valid and invalid, with bytes); `listInvalid` (including `two_cards_share_one_order`, whose `refusal` is the exact text both judges must produce: `order 4 is shared by <first fork id> and <second fork id>`); `listScenarios` (six scenarios: over one Mind's five cards, paged open-only, recorded-only, all and `limit` 0; an empty Mind; and `order_zero_is_legal_but_no_cursor_names_it`, four cards of orders 3 to 0 paged by 3, whose first page ends at order 1 with `c1` and whose second page is the order-0 card with `next_cursor` nil; each response with its bytes, the cursor of page two being page one's `next_cursor`); `listShortPages` (three scenarios: a page that stops short and resumes below its last entry; `fork_too_large` on a first entry of order 3, continued with `c3`; and a short page ending at order 1 followed by `fork_too_large` on order 0, where the client is at the end; each step carries its bytes, and the receiver's budget is in no vector, so the judges check a short page as a nonempty strict prefix of the unbudgeted page and a refusal as naming the entry the page would open with); `doorFaults` (three cases: a door `request_id_conflict` is `unavailable` 503 on read and list and itself 409 on the answer); `readTooLarge` (one case: a read whose entry is past the cap, refused `fork_too_large` 422 with the read-form text and its bytes); `answerTooLarge` (one case: a stale answer whose card re-read is past the cap, the same 422 document over a stored `Refused(stale_fork)`: `outcome` `Refused`, `code` `stale_fork`, `counted` false); `authCases` (twenty-two cases: the MAS token, three `votes.*` tokens, a desk token with the other route's scope, a non-Active member on each route, and Huginn down on each route, with auth and membership judged first). Both suites replay the list requests through a reference judge and the auth cases through another. `mindRoutes` (a configured list of two Minds with their sockets and forks; 57 cases over the three routes: bad and percent-encoded segments, unknown Mind against unknown fork with `sameResponse` pairs of equal bytes, the POST on a Mind that does not hold the fork, scopes that are not per Mind, and auth judged before the Mind lookup) is replayed by both suites through a reference judge that reports the refusal, the socket it would open and the Mind that stored an answer. Top-level `unavailable` pins the 503 document. Scenarios pin: an `unavailable` where Huginn never had the request, retried into a `Counted` answer; an `unavailable` where Huginn had stored it, whose retry returns the stored outcome and adds no Revision; a replayed `Refused` with its card; `request_id_conflict`; the same id from another account; and a non-Active member with a bad body, with the door down, getting `forbidden` first. A bad body is refused before the door is asked.
**Bifrost must still build:** all three routes; the `bifrost-desk` audience and the `desk.read` and `desk.answer` scopes on the OIDC token it issues to chat; the configured Mind list and its sockets (M1); the projection of cards and records from Huginn, the stamp, the cursor and the counts; the `DeskAnswer` call on the second door and the pass-through of its outcome; the decider list, sent with every answer, read and list request from the admin-set list only (M5); the `unavailable` answer. **Huginn must still build** the second door's wire, the per-call decider list it receives with each answer, read and list request, keeping no copy (M5), and the read side the projection uses (Eureka's cut spec, after her record PR). **The desk may build today** against the vectors: nothing in them needs a live service.
## Old format, the mind's record, and what maps to what
The old desk (`index.html`) read `questions/<id>` and wrote `answers/<id>` in a shared store. The mind's `question` record is the source in the new design; the card is its projection.
| old desk | mind `question` record | card |
|---|---|---|
| `qid`, `id` | key `campaign:question:label` | `fork_id` |
| `title` | `title` | `title` |
| `body` | `question` (the long text) | `context` |
| `depends`: a free string | `depends`: up to 8 prose lines (`huginn schema question`) | `depends`: the lines verbatim |
| `options[{key, label, text}]` | `options[{label, text}]` | `options[{id, text}]` |
| option `key` | option `label` (a slug, the id) | option `id` |
| option `label` (a human title) | none | **dropped**: not stored, not hashed; shown text is `text` |
| `rec` (a key) | `recommended` (a label) | `recommendation` (an id), never nil |
| `lean` ("Self leans" mark) | none | `leans` (an id or nil), not hashed |
| `flag` (the provisional line) | none | `flag` (a string or nil), not hashed |
| `status` open or closed, `ruling` | derived from the resolution (status is never written): any resolution of the question closes it; the ruling is the one that answered it | `status`, `ruling`, both a projection (`ruling` is nil on an open card and on a card closed with no ruling) |
| `order` | none (unique rank per Mind, from 0) | `order` |
| none | `raised_in`, `asked_on` | `raised_in`; `asked_on` is not carried |
| none | the question's admission time (no timestamp in the old format; `asked_on` is a date and stays uncarried) | `raised_at`, an instant or nil, not hashed |
| none | none | `deciders` (outside the Mind), `ruled_from`, `options_hash` |
| none | none | `decider_count`, `answerer_count` (counts that stand in for withheld ids) |
| answer `{choice: key or null, note, question, updatedAt}`, one mutable document per question | none | `answer_request` then an append-only `desk_answer` |
| `note`: her words verbatim | | `answer.note`, verbatim |
| "Clear pick": `choice` back to null so the note stands alone | | an `answer` with `choice` nil and `note` set, as the first answer or as a `Revision` |
| "Changing an answer is fine until it's admitted as a ruling" | | a later answer is a `Revision`; `Retraction` withdraws one; neither changes who counted first, and a ruling that answers the fork closes it, after which no answer or retraction is taken (`fork_closed`) |
Differences that need Void's choice: the old page **overwrites** one answer per question and the new store **appends**, so the booth cannot show "your answer" as one mutable field (it shows the latest answer by the person, plus its history); the old page needs no identity and the new one stamps `answerer` from a verified session; the old `label` has nowhere to live unless the card's `text` carries it.
## Transport note for Void: Matrix event versus how the mind carries questions
How a mind carries a question today: a typed `question` record in Huginn (`huginn view`, `huginn query`); the Persona relays it to the operator in chat, and the operator's reply comes back as chat text that Self reads and admits as a `ruling` in her words. Identity is whoever the chat attributes the message to; nothing binds an answer to the question text she saw.
Option A, **a Matrix event in the Mind's room with the answer as a reply.** Gains: it sits where people already are, needs no sign-in beyond Matrix, and the thread shows the exchange. Loses: the sender is an mxid, not a Bifrost account, so `answerer` and `authority` have no verified source; an edit or redaction changes the visible message but not the decision (binding rule 9), so the thread and the record can disagree; there is nowhere to carry `options_hash`, so a stale card cannot be refused; and a reply is free text, so `choice` is lost.
Option B, **Bifrost receives the answer and Huginn stores it** (the design in this page; Ratatoskr PR #24 also withdraws the Matrix carrier). Gains: a verified account, a bound hash, refusals that are stored and visible, one counted answer, retractions. Loses: the booth needs Bifrost sign-in, which does not reach chat yet (Bifrost PR #9 and #37 design it), so until then the desk is read-only; and the exchange is no longer in the room.
**Recommendation: B, with Matrix as presentation only.** The Mind may post a card as a listing and a link into the desk; no Matrix event is ever an answer (rule 9). Confidence: high on the rule (it is Heimdall's and Eureka's already), moderate on the interim, where read-only until sign-in is a real cost; if the operator needs to answer before sign-in reaches chat, the honest stopgap is the old path (chat text, Self admits a ruling in her words) rather than a Matrix reply the record cannot bind.
## Differences from the binding file
- Spelling: every field is as the binding file names it (`fork_id`, `options_hash`, `answered_at`, `retracted`, `retracts`, `answer.choice`, `answer.note`, `recommendation`, `depends`, option `id`), except that `schemaVersion` keeps CultNet's camelCase and `answer_id` replaces the earlier `receipt id` (Persona brief).
- Map-mode writes every key: `retracted` is always present; nothing is "optional".
- `Refused` stores every refusal from steps 4 to 7 and R1 and R2 (his section 1.3b); only steps 1 and 2 and `fork_unknown` are not stored. The binding file does not say whether `fork_unknown` is stored; it cannot be keyed without a question, so it is not (open choice 4).
- The binding file's `choice_valid` has no named refusal code; this page names it `choice_invalid`. `fork_known` is `fork_unknown`. A type-invalid body is `body_invalid`.
- `ruled` (his R3 and V10) is dropped: a ruling that answers a fork closes it, so the case is `fork_closed` (V4, V10a). His file drops it too.
- Heimdall's section 1.3e (Q1 to Q6, V12 to V18) is the source for `request_id`; this page matches it, with two notes. Q1's open item is settled by his ruling: a replayed `stale_fork` returns the current card. In V14 a decider's identical body is a `Revision` whose `of` necessarily names the first account's counted answer id (the record shows it to every decider), so "never contains X's answer_id" holds for Y's answer id and document, not for `of` (Heimdall: `of` naming the counted answer is allowed); the vector with an off-list first account pins the case with a `Counted`, which has no `of`.
- `request_id` (on the request and the stored `desk_answer`) and `request_id_conflict` are this page's: his file has no idempotency key, and a retry after `unavailable` needs one. Eureka's `desk_answer` kind and Huginn's request type must carry it.
- Transport: the routes, the `{mind}` segment, the scopes, the audience and the auth order are his rulings (4.1 to 4.3a); the documents (list, read, `unavailable`) are this page's. The note limit is 4000 UTF-8 bytes here, as his 1.1 leaves the unit unsaid.
- The recommendation is never nil. The binding file's earlier vectors D and E used a nil recommendation; its updated D and E use `"a"`, as here.
- `raised_at` (2026-11-11) is the desk-feed addition (Bifrost PR 67): a required key, an instant or nil, outside `options_hash` (the five hashed members and every digest are unchanged; vectors `raised_at_nil`, `raised_at_epoch_integer`, `raised_at_date_only`, `raised_at_missing`). The card is spec-authored and no runtime deploys it, so the schema id stays `gamecult.fork.card.v0`.
- Reader views (2026-11-11) are the desk-feed addition behind Huginn PR 56 and `eureka-substrate:ruling:desk-read-visibility-withheld-ids`: the card gains `decider_count` and `deciders` may be empty; the record gains `answerer_count` and `answerer` may be nil, the counts beside the collections they summarise (not on the list envelope, which neither the read entry nor the `stale_fork` card travels in). `options_hash` and every digest are unchanged. The schema ids stay `gamecult.fork.card.v0` and `gamecult.fork.record.v0` for the same reason as `raised_at`.
- Hash vectors: A, B, C, D, E, F and G of the binding file are reproduced digest for digest by the Python generator, the C# suite and the TypeScript suite (the generator prints `same` for each; none differs).
## Open choices
1. Case of field names: snake_case for the fork documents (as the binding file) beside camelCase for the vote documents (as Bifrost's serializer). One convention would be tidier.
2. NFC across runtimes relies on each runtime's normaliser; a pinned table, as `write-receipt.md` does for letters, would remove the reliance. The risk is only for code points assigned after a runtime's Unicode version.
3. How Huginn learns the verified subject: the second door's authenticated context (the peer credential names Bifrost the service, not the member), so the subject must travel beside the request. This contract has no envelope for it.
4. Whether `fork_unknown` and shape refusals should leave an audit record somewhere other than a `desk_answer`.
5. Whether `leans` and `flag`, which the booth shows, should join the hash. The binding file's five members do not include them; a changed lean would not make an answer stale. `raised_at` is immutable, so it was kept out of the hash for the same reason it need not be in: it can never make a shown card stale.
6. What the person sees of a retracted counted answer when the next answer counts: this contract only says the count moves.
7. `received_at` is not in the binding file's stored fields; it is kept for `claim_in_future` and could be dropped if the `n<seq>` answer ordinal alone decided answer order.
8. The stamped `authority` field is an attribution, not an authority (above); renaming it `attribution` would say so, at the price of a new key the door must also refuse in a body.
9. The claims Bifrost verifies are PR #24's open item 5. (The audience `bifrost-desk`, the three routes and the scope names are ruled by Heimdall.)
10. The second door's wire between Bifrost and Huginn (pending Eureka's cut spec). `unavailable` is a document of its own, outside the refusal set, so a desk can retry it; calls 1 and 2 use it too when Huginn is down (vectors `huginn_down_*`).
11. While a known Mind's door is down its routes answer `unavailable` (503) and an unknown Mind answers `fork_unknown` (404), so the two are distinguishable for that period. M2 makes them identical only when every door is up; making them identical when one is down means answering `unavailable` for any `{mind}` not on the list, which tells a client nothing about which Minds exist but also hides a typo. Heimdall's to rule; the vectors do not pin it.
12. A fork's answers are unbounded in number, so a fork's entry can grow past the reply cap and become unreadable (`fork_too_large` on read, and on list when it is the first entry of a page). Whether the contract should cap the answers per fork is for Persona and Heimdall to rule; nothing here decides it.
13. The read form of `fork_too_large` is a target, not current behaviour: Huginn's door answers an over-cap read as `Refused(ResponseTooLarge{bytes, limit})`, which carries no fork id and is not `fork_too_large`. Bifrost maps it to `fork_too_large` (422) using the route's `fork_id`; until it does, a read past the cap reaches the client as 503 `unavailable`. The same holds for the answer route: a `stale_fork` answer whose card re-read is past the cap is a Bifrost target too (`fork_too_large` 422 over a stored `Refused(stale_fork)`), and until it is built the client sees 503 `unavailable`.
14. What a non-decider can still infer: the card's `status` and `order`, the `authority` role on each answer (a role, not an id, and the ruling leaves it whole), and `answerer_count`. Whether a role plus a count is too much to show is Eureka's ruling to revisit.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,107 +0,0 @@
# CultNet vote API v0 (specification)
Status: specification, drafted for Heimdall (Bifrost) and a Ratatoskr voting module. No server implements it yet.
Files here: `vote-api.schema.json` (one schema, a `$defs` entry per document), `vote-api-vectors.json`. Conformance suites: `tests/GameCult.Networking.Tests/GovernanceVectorTests.cs` (C#) and `packages/cultnet-ts/test/governance-vectors.test.ts` (TypeScript). The vectors are written by `scripts/gen-governance-vectors.py`.
Facts it rests on: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Bifrost `416f11a`; `416f11a..276f611` changes four files: a Standard-close concurrency fix in `MotionGovernanceService.cs`, two test files and `docs/deploy-main-to-live.md`; none touches the sealed API, so the facts hold at `276f611`) and Bifrost PR #37 (`docs/ratatoskr-voting-auth.md`, see "PR #37" below).
## Scope
**Which API covers which.** This contract covers **sealed-ballot motions only** (`gamecult.sealed_ballot.v0`). **Standard motions are not covered here.** Bifrost PR #37 also serves them (`PUT /api/votes/motions/{id}/ballot`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`), and that API is #37's own: this page defines no document for it, and a Standard motion id sent to any exchange below is answered `not_found`. Today Standard motions have no JSON read or cast API at all, no Mind path, and no receipt (open choice 1). The contract names four exchanges and one refusal document:
| exchange | request | response |
|---|---|---|
| motion list | `gamecult.vote.motion_list_request.v0` | `gamecult.vote.motion_list.v0` |
| motion read | `gamecult.vote.motion_read_request.v0` | `gamecult.vote.motion.v0` |
| cast (commit or reveal) | `gamecult.vote.cast.v0` | `gamecult.vote.receipt.v0` |
| any refusal | | `gamecult.vote.refusal.v0` |
Every document is CultNet map-mode: all keys are written, nil where absent, and the schema is closed (`additionalProperties: false`). Instants are RFC 3339 UTC with a `Z` and up to seven fractional digits. Ids are lowercase `D`-format UUIDs. A motion states `highStakes`, a boolean fixed when the motion opens and never changed after (Bifrost PR #37, D2): it is the property that makes a commit demand a fresh sign-in (`reauthentication_required`). The bytes of every document are pinned by `messagePackHex` in the vectors (shortest integers, shortest strings, keys in the order the schema lists them); JSON with the same keys is the xenos-boundary form of the same documents (open choice 4: the wire format).
## Documents
- **motion_list_request**: `{schemaVersion, phase}`; `phase` is `Commit`, `Reveal`, `Closed` or nil for all.
- **motion_list**: `{schemaVersion, motions: [{id, title, round, phase, commitClosesAtUtc, closesAtUtc, highStakes}]}`. **Heimdall must add this endpoint**: Bifrost has no list route for sealed motions today, only fetch-by-id. No pagination in v0 (open choice 2).
- **motion_read_request**: `{schemaVersion, motionId}`.
- **motion**: Bifrost's `SealedMotionState` with its names: `{schemaVersion, id, title, summary, round, priorMotionId, ballotSchema, phase, candidates (two or more), commitClosesAtUtc, closesAtUtc, highStakes, yourVoterId, yourCommitment (a receipt or nil), announcement}`. It never carries a tally, a turnout, or another voter's commitment or ballot (a `tally` key is a negative vector). `yourVoterId` is the voter's `UserAccount.Id`, the `voterUserAccountId` of the commitment.
- **cast**: `{schemaVersion, motionId, verb, commitment, ballot}` with exactly one of two shapes: `verb: "commit"` with a 64-hex `commitment` and `ballot: nil`, or `verb: "reveal"` with `commitment: nil` and a `ballot`. One document for both verbs; today's two routes (`/commit`, `/reveal`) may stay, taking this document as the body, with a `verb` that must match the route (open choice 3).
- **ballot** (inside a reveal): `{schema: "gamecult.sealed_ballot.v0", motionId, round, candidate, reason, salt, persona}`. `candidate` and `reason` are single-line and **already trimmed** (no Unicode White_Space at either end, using the White_Space list of `write-receipt.md`); the contract refuses an untrimmed value rather than trimming it, so the bytes hashed are the bytes sent. **This is a behaviour change Bifrost must make:** it trims `candidate` and `reason` silently on reveal today (facts file section 3c, `SealedBallotService.cs:154, 385-387`), so an untrimmed reveal is accepted and hashed trimmed; under this contract it is `invalid` (open choice 5). `reason` is 1 to 280 characters counted in UTF-16 code units, as Bifrost counts. A JSON Schema `maxLength` counts code points, so the schema alone accepts up to 280 astral characters; both suites add the rule (vectors `cast_reveal_reason_140_astral_280_units` valid, `ballot_reason_141_astral_282_units` refused). `salt` is 32 to 128 lowercase hex. `persona` is `""` for a member and a printable-ASCII, space-free id of at most 64 characters for a Mind.
- **receipt**: Bifrost's `SealedBallotReceipt`: `{schemaVersion, motionId, voterUserAccountId, commitment, revision (>= 1), committedAtUtc, revealed}`. The receipt for a reveal has `revealed: true` and **echoes nothing of the ballot** (no candidate, reason or salt; negative vectors for each).
- **refusal**: `{schemaVersion, code, error, retryAfterSeconds}`; the `code` set is closed:
| code | HTTP | trigger |
|---|---|---|
| `unauthenticated` | 401 | no bearer, or one Heimdall does not know (the API must answer this, not redirect to a sign-in page) |
| `reauthentication_required` | 401 | a commit on a `highStakes` motion with no sign-in fresh enough (#37 D2: `prompt=login`, `max_age`); the voter signs in again at the top level and retries |
| `forbidden` | 403 | authenticated but not an Active member, or a Mind key used on a route it may not call |
| `not_found` | 404 | unknown motion id, a Standard motion id, or a reveal with no prior commit |
| `wrong_phase` | 409 | commit outside Commit, reveal outside Reveal |
| `contended` | 409 | a reveal raced the close fence: send it again |
| `invalid` | 400 | the body fails the schema or an in-document rule (untrimmed text, candidate not in the motion, `round` or `motionId` mismatch, `persona` wrong for the credential) |
| `commitment_mismatch` | 400 | a reveal whose canonical bytes do not hash to the stored commitment |
| `payload_too_large` | 413 | body over 4096 bytes |
| `rate_limited` | 429 | cast rate exceeded, per account; `retryAfterSeconds >= 1`, mirrored in `Retry-After` |
| `held` | 503 | the Ratatoskr voting brake is set (#37 D5): nothing is read or stored while it is |
Each code has a vector (`refusals`), and every failure of the schema has a negative vector (`invalid`).
**Not on Bifrost's member API yet.** The **413 body cap of 4096 bytes** and the **429 per-account cast limit** are not on Bifrost's member sealed API today (facts file sections 3c and 5): both exist only on the Mind intake route (4096 bytes; 30 requests a minute per source address). #37 proposes 30 casts per 10 minutes per account for the member routes (its D6). `reauthentication_required` and `held` are #37 proposals (D2, D5) and exist nowhere in code. All four are spec text until Heimdall builds them.
**Two refusals Bifrost must split first.** Bifrost returns a reveal whose bytes do not hash to the stored commitment as a plain `400` `Invalid` ("The ballot does not match the stored commitment."), the same status and error type as every other bad body, so `commitment_mismatch` and `invalid` are one today. It returns a contended reveal (a race with the close fence, `SealedBallotService.cs:186-189`) as `WrongPhase` `409`, the same type as a reveal outside the Reveal phase, so `contended` and `wrong_phase` are one today. A client cannot tell "send it again" from "too late", or "wrong ballot" from "malformed body", until Bifrost splits each pair.
## Commitment
`commitment = lowercase hex SHA-256 of the UTF-8 bytes of`
gamecult.sealed_ballot.v0 \n {motionId} \n {voterUserAccountId} \n {persona} \n {round} \n {candidate} \n {reason} \n {salt}
with `\n` U+000A, ids in `D` format, `round` in decimal, `persona` empty for a member. This is Bifrost's formula as the code has it (`SealedBallotService.cs:154, 385-387`); `docs/sealed-ballot-motion.md:22-23` omits `{persona}` and is stale. The vectors give the canonical bytes (`canonicalHex`) and the digest for a member, a Mind, non-ASCII text, and a decomposed-versus-precomposed pair (no normalisation: they are different ballots). `pairs` pins what a reveal must match: the right ballot matches; a changed reason, another voter id, or a dropped persona does not, and the answer is `commitment_mismatch`.
## Receipts the voter can check later
A cast returns the receipt. The voter checks it later in two places that must agree byte for byte with it: `motion.yourCommitment` on a motion read (a commit receipt is present from the commit; the revision counts re-commits), and, after close, the `commitment` the result lists for them. The contract adds no receipt id or signature: the receipt is the commitment, its revision and the time (open choice 6).
## Personas
A Persona votes only on sealed motions, through a Mind key (Bifrost binds an Ed25519 public key 1:1 to an Active member account). The ballot's `persona` is the bound persona id and the commitment covers it, so a Mind's ballot cannot be replayed as a member's (`pairs`: dropping the persona does not match). The Mind-intake route today takes a signed `MindEnvelope` anonymously; this contract does not model that envelope, nonce or signature (open choice 7). Standard motions have no Mind path.
## Authentication
On the wire the credential is an opaque `Authorization: Bearer` header. The token route, claims, scopes and audience belong to Heimdall and Bifrost and are not designed here. A request without a usable bearer is answered `unauthenticated` as a typed refusal.
**Auth per route, now and after #37.** Routes are Bifrost's; this contract fixes the documents, not the paths (open choice 3).
| exchange | today (`/api/sealed-motions`, facts file 3c) | after #37 (`/api/votes`) |
|---|---|---|
| motion list | no route | bearer, scope `votes.read` |
| motion read | session cookie + Active member | bearer, scope `votes.read` |
| commit, reveal | session cookie + Active member | bearer, scope `votes.cast`, audience `bifrost-votes`; a high-stakes commit also needs a fresh sign-in |
| Mind cast | anonymous; the Ed25519 signature on a `MindEnvelope` is the credential | unchanged: Minds never use `/api/votes` (#37) |
| open a motion, Mind-key registry | session cookie + moderator | unchanged, cookie only |
Today a member route has **no bearer scheme**: it accepts the host-only `__Host-bifrost_session` cookie and answers an unauthenticated call with a redirect to the sign-in page, not a 401. After #37 `/api/votes` is bearer-only and answers a typed 401. The wire header stays an opaque bearer either way; only Bifrost knows which of the two it is checking.
## PR #37 (Bifrost `docs/ratatoskr-voting-auth.md`, head `447a7cb`)
Checked 2026-11-10 by an Eyes pass over the PR and the schema; conflicts for Heimdall:
1. **Standard motions.** The PR serves them (`PUT /api/votes/motions/{id}/ballot {choice, comment?}`, a receipt `{motionId, choice, revision, castAtUtc, receiptId}`); the contract is sealed-only and answers `not_found` for a Standard id.
2. **Verb model.** The PR keeps `POST .../commit` and `.../reveal` and adds `GET .../result`; the contract has one `cast` document and no result document.
3. **List and read as GET.** The PR names GET routes; the contract's request documents carry a `phase` filter and a `motionId`. The mapping (query string and path) is undefined until Heimdall fixes the routes.
4. **Refusals.** The PR lists 401/403/404/409/429 with `WWW-Authenticate`; the contract adds 400 (`invalid`, `commitment_mismatch`) and 413, and a typed body.
5. **Minds.** The PR says Minds never use `/api/votes` and keeps the anonymous signed `/mind` route; the contract carries `persona` in the ballot and a `forbidden` case for a Mind key on a route it may not call. The PR's token is never a Persona's.
6. **Authority.** The contract's `unauthenticated` says a bearer "Heimdall does not know"; the PR makes Bifrost the OIDC issuer (a second client, scopes `votes.read` and `votes.cast`, audience `bifrost-votes`) and notes that Bifrost OIDC is not live (its D7).
7. **Rate limits.** The PR keys per account (30 casts per 10 minutes); the contract now says per account too, and neither exists on the member API yet (above).
8. **Wire format.** The PR's errors are JSON; the contract's vectors are MessagePack. Nothing settles the media type.
9. **The PR's seven open decisions** (consent for `votes.cast`, re-authentication, no refresh token, grant revocation, a Ratatoskr brake, rate numbers, MAS rollout order) assume nothing the contract contradicts. D2 (re-authentication at cast) is answered yes for sealed commit in this contract: `reauthentication_required`, with `highStakes` fixed at open. D5 (a brake) is `held`.
## Open choices for Heimdall
1. Standard motions in v0 or a v1 (the PR already serves them).
2. Pagination and filters on the list.
3. One `cast` route or the two existing ones; `verb` redundancy.
4. Wire format: MessagePack documents with a JSON export, or JSON only (the PR is JSON).
5. Refuse untrimmed `candidate` and `reason` (this contract) or keep trimming server-side.
6. Whether a receipt should carry an id or a server signature so a voter can prove it to a third party.
7. The Mind path: keep the signed envelope on its own route, or take Mind keys as a bearer credential.
8. `reason` length in UTF-16 code units (as Bifrost) or code points.
9. Whether the result document (`GET .../result`) joins this contract.

View file

@ -1,420 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/governance/vote-api.schema.json",
"title": "CultNet vote API v0 (sealed-ballot motions)",
"description": "Typed documents for listing, reading and casting on sealed-ballot motions, and the receipt a cast returns. Map-mode: every key is written, nil where absent. Spec: vote-api.md. Vectors: vote-api-vectors.json.",
"$defs": {
"uuid": {
"type": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"instant": {
"type": "string",
"pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,7})?Z$"
},
"hex64": {
"type": "string",
"pattern": "^[0-9a-f]{64}$"
},
"trimmedLine": {
"type": "string",
"minLength": 1,
"pattern": "^(?![\\t-\\r \\u0085   - 

   ])[^\\n\\r\\u0085

]+(?<![\\t-\\r \\u0085   - 

   ])$"
},
"ballot": {
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"motionId",
"round",
"candidate",
"reason",
"salt",
"persona"
],
"properties": {
"schema": {
"const": "gamecult.sealed_ballot.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"round": {
"type": "integer",
"minimum": 1
},
"candidate": {
"$ref": "#/$defs/trimmedLine"
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 280,
"pattern": "^(?![\\t-\\r \\u0085   - 

   ])[^\\n\\r\\u0085

]+(?<![\\t-\\r \\u0085   - 

   ])$"
},
"salt": {
"type": "string",
"pattern": "^[0-9a-f]{32,128}$"
},
"persona": {
"type": "string",
"pattern": "^[!-~]{0,64}$"
}
}
},
"motionListRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"phase"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion_list_request.v0"
},
"phase": {
"oneOf": [
{
"enum": [
"Commit",
"Reveal",
"Closed"
]
},
{
"type": "null"
}
]
}
}
},
"motionListItem": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"title",
"round",
"phase",
"commitClosesAtUtc",
"closesAtUtc",
"highStakes"
],
"properties": {
"id": {
"$ref": "#/$defs/uuid"
},
"title": {
"type": "string",
"minLength": 1
},
"round": {
"type": "integer",
"minimum": 1
},
"phase": {
"enum": [
"Commit",
"Reveal",
"Closed"
]
},
"commitClosesAtUtc": {
"$ref": "#/$defs/instant"
},
"closesAtUtc": {
"$ref": "#/$defs/instant"
},
"highStakes": {
"type": "boolean",
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
}
}
},
"motionList": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motions"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion_list.v0"
},
"motions": {
"type": "array",
"items": {
"$ref": "#/$defs/motionListItem"
}
}
}
},
"motionReadRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion_read_request.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
}
}
},
"receipt": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId",
"voterUserAccountId",
"commitment",
"revision",
"committedAtUtc",
"revealed"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.receipt.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"voterUserAccountId": {
"$ref": "#/$defs/uuid"
},
"commitment": {
"$ref": "#/$defs/hex64"
},
"revision": {
"type": "integer",
"minimum": 1
},
"committedAtUtc": {
"$ref": "#/$defs/instant"
},
"revealed": {
"type": "boolean"
}
}
},
"motion": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"id",
"title",
"summary",
"round",
"priorMotionId",
"ballotSchema",
"phase",
"candidates",
"commitClosesAtUtc",
"closesAtUtc",
"highStakes",
"yourVoterId",
"yourCommitment",
"announcement"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.motion.v0"
},
"id": {
"$ref": "#/$defs/uuid"
},
"title": {
"type": "string",
"minLength": 1
},
"summary": {
"type": "string"
},
"round": {
"type": "integer",
"minimum": 1
},
"priorMotionId": {
"oneOf": [
{
"$ref": "#/$defs/uuid"
},
{
"type": "null"
}
]
},
"ballotSchema": {
"const": "gamecult.sealed_ballot.v0"
},
"phase": {
"enum": [
"Commit",
"Reveal",
"Closed"
]
},
"candidates": {
"type": "array",
"minItems": 2,
"items": {
"$ref": "#/$defs/trimmedLine"
}
},
"commitClosesAtUtc": {
"$ref": "#/$defs/instant"
},
"closesAtUtc": {
"$ref": "#/$defs/instant"
},
"highStakes": {
"type": "boolean",
"description": "Fixed when the motion opens and never changed after: a high-stakes sealed motion needs a fresh sign-in at commit (Bifrost PR #37, D2)."
},
"yourVoterId": {
"$ref": "#/$defs/uuid"
},
"yourCommitment": {
"oneOf": [
{
"$ref": "#/$defs/receipt"
},
{
"type": "null"
}
]
},
"announcement": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
}
},
"commitCast": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId",
"verb",
"commitment",
"ballot"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.cast.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"verb": {
"const": "commit"
},
"commitment": {
"$ref": "#/$defs/hex64"
},
"ballot": {
"type": "null"
}
}
},
"revealCast": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"motionId",
"verb",
"commitment",
"ballot"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.cast.v0"
},
"motionId": {
"$ref": "#/$defs/uuid"
},
"verb": {
"const": "reveal"
},
"commitment": {
"type": "null"
},
"ballot": {
"$ref": "#/$defs/ballot"
}
}
},
"cast": {
"oneOf": [
{
"$ref": "#/$defs/commitCast"
},
{
"$ref": "#/$defs/revealCast"
}
]
},
"refusal": {
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"code",
"error",
"retryAfterSeconds"
],
"properties": {
"schemaVersion": {
"const": "gamecult.vote.refusal.v0"
},
"code": {
"enum": [
"unauthenticated",
"forbidden",
"reauthentication_required",
"not_found",
"wrong_phase",
"contended",
"invalid",
"commitment_mismatch",
"payload_too_large",
"rate_limited",
"held"
]
},
"error": {
"type": "string",
"minLength": 1
},
"retryAfterSeconds": {
"oneOf": [
{
"type": "integer",
"minimum": 1
},
{
"type": "null"
}
]
}
}
}
}
}

View file

@ -1,346 +0,0 @@
{
"vectors": [
{
"name": "snapshot_request_v1_defaults",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWShbalzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywKdzaGFyZElkwKpzaGFyZEVwb2NowA==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"shardId": null,
"shardEpoch": null
}
},
{
"name": "snapshot_request_v1_full_selection_and_shard",
"schemaVersion": "cultnet.snapshot_request.v1",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWSmbS1mdWxsqXNlbGVjdGlvbomnc2NoZW1hc5KpbGVhZl9hLnY52UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqRrZXlzkqRhLWVxqPCfmIAtcm93pmZpZWxkc5KEpWluZGV4pGtpbmSib3CmYW55X29mpnZhbHVlc5Kmd2VhcG9upHRvb2ymbnVtYmVywISlaW5kZXikbWFzc6JvcKJnZaZ2YWx1ZXPApm51bWJlcqstMjIzMzc1OS4yNaVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26lY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqhkb2N1bWVudKpkZXNjZW5kaW5nw6VsaW1pdMzIpmN1cnNvcq1vcGFxdWUtY3Vyc29yp3NoYXJkSWSnc2hhcmQtMapzaGFyZEVwb2NoBw==",
"expected": {
"schemaVersion": "cultnet.snapshot_request.v1",
"messageId": "m-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"shardId": "shard-1",
"shardEpoch": 7
}
},
{
"name": "database_subscribe_v1_defaults",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKFtrnN1YnNjcmlwdGlvbklko3N1YqlzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywK9pbmNsdWRlU25hcHNob3TDsWNvbnN1bWVyUnVudGltZUlkwKdib2R5SWRzwLdzdXBwb3J0ZWRCb2R5VHJhbnNwb3J0c8A=",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m",
"subscriptionId": "sub",
"selection": {
"schemas": null,
"keys": null,
"fields": null,
"cites": null,
"cited": null,
"projection": "header",
"descending": false,
"limit": null,
"cursor": null
},
"includeSnapshot": true,
"consumerRuntimeId": null,
"bodyIds": null,
"supportedBodyTransports": null
}
},
{
"name": "database_subscribe_v1_full",
"schemaVersion": "cultnet.database_subscribe.v1",
"messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKZtLWZ1bGyuc3Vic2NyaXB0aW9uSWSoc3ViLWZ1bGypc2VsZWN0aW9uiadzY2hlbWFzkqlsZWFmX2EudjnZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmpGtleXOSpGEtZXGo8J\u002BYgC1yb3emZmllbGRzkoSlaW5kZXika2luZKJvcKZhbnlfb2amdmFsdWVzkqZ3ZWFwb26kdG9vbKZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqy0yMjMzNzU5LjI1pWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqVjaXRlZIKkcm9sZaZEZXNpZ26mZXhpc3Rzwqpwcm9qZWN0aW9uqGRvY3VtZW50qmRlc2NlbmRpbmfDpWxpbWl0zMimY3Vyc29yrW9wYXF1ZS1jdXJzb3KvaW5jbHVkZVNuYXBzaG90wrFjb25zdW1lclJ1bnRpbWVJZKhjb25zdW1lcqdib2R5SWRzkaZib2R5LWG3c3VwcG9ydGVkQm9keVRyYW5zcG9ydHOSpHJ1ZHCkcXVpYw==",
"expected": {
"schemaVersion": "cultnet.database_subscribe.v1",
"messageId": "m-full",
"subscriptionId": "sub-full",
"selection": {
"schemas": [
"leaf_a.v9",
"sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"
],
"keys": [
"a-eq",
"\uD83D\uDE00-row"
],
"fields": [
{
"index": "kind",
"op": "any_of",
"values": [
"weapon",
"tool"
],
"number": null
},
{
"index": "mass",
"op": "ge",
"values": null,
"number": "-2233759.25"
}
],
"cites": {
"target": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"role": "Design"
},
"cited": {
"role": "Design",
"exists": false
},
"projection": "document",
"descending": true,
"limit": 200,
"cursor": "opaque-cursor"
},
"includeSnapshot": false,
"consumerRuntimeId": "consumer",
"bodyIds": [
"body-a"
],
"supportedBodyTransports": [
"rudp",
"quic"
]
}
},
{
"name": "snapshot_response_raw_v1_headers",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAqRhc09mBaRuZXh0q25leHQtY3Vyc29yp2hlYWRlcnOSiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1lcahzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3PAiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1sb6hzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3OSpWFscGhhpGJldGGpZG9jdW1lbnRzwKVlZGdlc8Cnc2hhcmRJZKdzaGFyZC0xqnNoYXJkRXBvY2gDsHNoYXJkTG9nU2VxdWVuY2Up",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 2,
"asOf": 5,
"next": "next-cursor",
"headers": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": null
},
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": "leaf_a",
"schemaVersion": "1",
"schemaContentHash": "abc",
"recordKey": "a-lo",
"storedAt": "1970-01-01T00:00:00.000Z",
"sourceRuntimeId": "rt",
"sourceAgentId": "agent",
"sourceRole": "role",
"tags": [
"alpha",
"beta"
]
}
],
"documents": null,
"edges": null,
"shardId": "shard-1",
"shardEpoch": 3,
"shardLogSequence": 41
}
},
{
"name": "snapshot_response_raw_v1_documents_and_edges",
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAaRhc09mBaRuZXh0wKdoZWFkZXJzwKlkb2N1bWVudHORjKhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZcCtc2NoZW1hVmVyc2lvbsCxc2NoZW1hQ29udGVudEhhc2jAqXJlY29yZEtleaRhLWVxqHN0b3JlZEF0uDE5NzAtMDEtMDFUMDA6MDA6MDAuMDAwWq9wYXlsb2FkRW5jb2RpbmerbWVzc2FnZXBhY2uncGF5bG9hZMQEgaFrAa9zb3VyY2VSdW50aW1lSWTArXNvdXJjZUFnZW50SWTAqnNvdXJjZVJvbGXApHRhZ3PApWVkZ2VzkoWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nwKdwYXlsb2FkwIWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nq21lc3NhZ2VwYWNrp3BheWxvYWTEAwECA6dzaGFyZElkwKpzaGFyZEVwb2NowLBzaGFyZExvZ1NlcXVlbmNlwA==",
"expected": {
"schemaVersion": "cultnet.snapshot_response_raw.v1",
"messageId": "m",
"matched": 1,
"asOf": 5,
"next": null,
"headers": null,
"documents": [
{
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"schemaName": null,
"schemaVersion": null,
"schemaContentHash": null,
"recordKey": "a-eq",
"storedAt": "1970-01-01T00:00:00.000Z",
"payloadEncoding": "messagepack",
"payload": "gaFrAQ==",
"sourceRuntimeId": null,
"sourceAgentId": null,
"sourceRole": null,
"tags": null
}
],
"edges": [
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": null,
"payload": null
},
{
"from": {
"schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc",
"recordKey": "citer-1"
},
"role": "Design",
"to": {
"schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff",
"recordKey": "a-eq"
},
"payloadEncoding": "messagepack",
"payload": "AQID"
}
],
"shardId": null,
"shardEpoch": null,
"shardLogSequence": null
}
},
{
"name": "error_plain",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KkYm9vbatyb3V0aW5nSGludMCkY29kZcCnZGV0YWlsc8A=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "boom",
"routingHint": null,
"code": null,
"details": null
}
},
{
"name": "error_selection_invalid",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3LZOnNlbGVjdGlvbl9pbnZhbGlkOiBmaWVsZHNbMF0uaW5kZXggImtpbmQiIGlzIG5vdCBkZWNsYXJlZC6rcm91dGluZ0hpbnTApGNvZGWxc2VsZWN0aW9uX2ludmFsaWSnZGV0YWlsc4SlZmllbGSvZmllbGRzWzBdLmluZGV4pXZhbHVlpGtpbmSkYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: fields[0].index \u0022kind\u0022 is not declared.",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "fields[0].index",
"value": "kind",
"asOf": null,
"current": null
}
}
},
{
"name": "error_cursor_stale",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX3N0YWxlOiBzdGFsZatyb3V0aW5nSGludMCkY29kZaxjdXJzb3Jfc3RhbGWnZGV0YWlsc4SlZmllbGTApXZhbHVlwKRhc09mA6djdXJyZW50CQ==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_stale: stale",
"routingHint": null,
"code": "cursor_stale",
"details": {
"field": null,
"value": null,
"asOf": 3,
"current": 9
}
}
},
{
"name": "error_cursor_invalid_no_details",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX2ludmFsaWQ6IGJhZKtyb3V0aW5nSGludMCkY29kZa5jdXJzb3JfaW52YWxpZKdkZXRhaWxzwA==",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_invalid: bad",
"routingHint": null,
"code": "cursor_invalid",
"details": null
}
},
{
"name": "error_write_not_authorized",
"schemaVersion": "cultnet.error.v0",
"messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3K0d3JpdGVfbm90X2F1dGhvcml6ZWSrcm91dGluZ0hpbnTApGNvZGW0d3JpdGVfbm90X2F1dGhvcml6ZWSnZGV0YWlsc4SlZmllbGSpcmVjb3JkS2V5pXZhbHVloWukYXNPZsCnY3VycmVudMA=",
"expected": {
"schemaVersion": "cultnet.error.v0",
"error": "write_not_authorized",
"routingHint": null,
"code": "write_not_authorized",
"details": {
"field": "recordKey",
"value": "k",
"asOf": null,
"current": null
}
}
}
]
}

View file

@ -1,122 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.error.v1.schema.json",
"title": "CultNet Error Message v1",
"description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum plus limit_reached (write-receipt.md, Remembered-write limits).",
"type": "object",
"required": [
"schemaVersion",
"error",
"routingHint",
"code",
"details",
"messageId"
],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"const": "cultnet.error.v1"
},
"error": {
"type": "string",
"minLength": 1
},
"routingHint": {
"description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).",
"type": [
"object",
"null"
],
"additionalProperties": false,
"required": [
"shard",
"reason"
],
"properties": {
"shard": {
"type": [
"object",
"null"
]
},
"reason": {
"type": "string"
}
}
},
"code": {
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) or its server-wide memory budget is full; messageId is the write's id.",
"type": [
"string",
"null"
],
"enum": [
null,
"selection_invalid",
"cursor_stale",
"cursor_invalid",
"reference_outside_target",
"variant_unsupported",
"unowned_schema",
"write_not_authorized",
"unsupported_schema_version",
"message_invalid",
"limit_reached"
]
},
"details": {
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
"type": [
"object",
"null"
],
"additionalProperties": false,
"required": [
"field",
"value",
"asOf",
"current"
],
"properties": {
"field": {
"description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read). limit_reached: no field; details stay all null. message_invalid also names \"messageId\" (over 256 bytes), \"document\", \"schemaId\" or \"channel\" (write on an unreliable channel).",
"type": [
"string",
"null"
]
},
"value": {
"description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.",
"type": [
"string",
"null"
]
},
"asOf": {
"description": "cursor_stale: the cursor's own minted asOf.",
"type": [
"integer",
"null"
],
"minimum": 0
},
"current": {
"description": "cursor_stale: the answering server's current asOf.",
"type": [
"integer",
"null"
],
"minimum": 0
}
}
},
"messageId": {
"description": "The messageId of the write (or other request) this error answers, echoed byte for byte. Null only when the frame was unreadable or carried no messageId.",
"type": [
"string",
"null"
],
"minLength": 1
}
}
}

View file

@ -1,16 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.write-admitted.schema.json",
"title": "CultNet Write Admitted",
"description": "The receipt for one admitted document put or delete (write-receipt.md). Sent only after the write's commit point, only to a connection that declared the write-receipt capability, once per messageId. Map-mode: every key is written, none is nil. A receipt never carries an error, and an error.v1 never carries admission.",
"type": "object",
"required": ["schemaVersion", "messageId", "operation", "schemaId", "recordKey"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.write_admitted.v0" },
"messageId": { "description": "The messageId of the put or delete this admits, echoed byte for byte.", "type": "string", "minLength": 1 },
"operation": { "enum": ["put", "delete"] },
"schemaId": { "description": "The schemaId of the admitted write, echoed.", "type": "string", "minLength": 1 },
"recordKey": { "description": "The recordKey of the admitted write, echoed.", "type": "string", "minLength": 1 }
}
}

View file

@ -1,166 +0,0 @@
{
"provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.",
"vectors": [
{
"name": "selection_invalid_schemas_null",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: must not be empty",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "schemas",
"value": null,
"asOf": null,
"current": null
}
}
},
{
"name": "selection_invalid_field_value",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "selection_invalid: blank key entry",
"routingHint": null,
"code": "selection_invalid",
"details": {
"field": "keys",
"value": " ",
"asOf": null,
"current": null
}
}
},
{
"name": "cursor_stale",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_stale: cursor is stale",
"routingHint": null,
"code": "cursor_stale",
"details": {
"field": null,
"value": null,
"asOf": 1,
"current": 2
}
}
},
{
"name": "cursor_invalid",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "cursor_invalid: cannot parse cursor",
"routingHint": null,
"code": "cursor_invalid",
"details": null
}
},
{
"name": "reference_outside_target",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "reference_outside_target: outside declared target",
"routingHint": null,
"code": "reference_outside_target",
"details": null
}
},
{
"name": "variant_unsupported_subscription_ended",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "variant unsupported here",
"routingHint": null,
"code": "variant_unsupported",
"details": {
"field": "subscriptionId",
"value": "sub-1",
"asOf": null,
"current": null
}
}
},
{
"name": "variant_unsupported_no_subscription",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "variant unsupported here",
"routingHint": null,
"code": "variant_unsupported",
"details": null
}
},
{
"name": "unowned_schema",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.",
"routingHint": null,
"code": "unowned_schema",
"details": {
"field": "schemaId",
"value": "some.schema.v1",
"asOf": null,
"current": null
}
}
},
{
"name": "write_not_authorized",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "write_not_authorized: not granted",
"routingHint": null,
"code": "write_not_authorized",
"details": {
"field": "recordKey",
"value": "some:key",
"asOf": null,
"current": null
}
}
},
{
"name": "unsupported_schema_version",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "unsupported_schema_version: cultnet.hello.v1",
"routingHint": null,
"code": "unsupported_schema_version",
"details": {
"field": "schemaVersion",
"value": "cultnet.hello.v1",
"asOf": null,
"current": null
}
}
},
{
"name": "message_invalid",
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
"message": {
"schemaVersion": "cultnet.error.v0",
"error": "message_invalid: Raw put messages must contain a document map.",
"routingHint": null,
"code": "message_invalid",
"details": {
"field": "document",
"value": null,
"asOf": null,
"current": null
}
}
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,89 +0,0 @@
# CultNet write receipt (specification)
Status: specification. No runtime implements it yet; the conformance follow-ups below do.
Rules: Heimdall's ruling of 2026-11-08 (a write completes on its result frame; an ack or a return is not admission).
Files here: `cultnet.write-admitted.schema.json`, `cultnet.error.v1.schema.json`, `write-receipt-vectors.json`. They move to `contracts/cultnet/` when the first runtime implements them.
## The problem
A put or delete today has two answers on the wire: nothing (admitted, or the server never read it) and `cultnet.error.v0` (refused, with no id saying which write). A transport ack proves delivery. Clients that completed on the ack, or on a window with no refusal, reported refused writes as done (CultLib #55 to #58 bound this with a result window; this spec replaces the window with an answer).
## Frames
**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write.
**A `write_admitted` `messageId` is valid only if it contains at least one Unicode letter (`\p{L}`) or number (`\p{N}`) and no Default_Ignorable_Code_Point (DerivedCoreProperties).** The compare is raw: the id is echoed byte for byte, so a decoder never trims or normalizes it (`" a "` is valid; U+2800 is not, and neither is `a` followed by U+200B). Unlike the Required fields rule, this is not a White_Space test. The letters, numbers and Default_Ignorable code points are those of **Unicode 16.0** in every runtime, taken from tables embedded in each runtime and generated by `scripts/gen-unicode-messageid-tables.py` from `DerivedGeneralCategory.txt` (Lu Ll Lt Lm Lo Nd Nl No) and `DerivedCoreProperties.txt`; no runtime asks its platform's Unicode data, so a runtime's own Unicode version cannot change a result. Bumping the version is one coordinated change: regenerate the five tables and update the vectors that pin the version in the same commit. The rule is on the decoded string, which must be well-formed Unicode: a lone surrogate refuses. A runtime whose MessagePack reader rejects invalid UTF-8 refuses the frame earlier; byte-level strictness is a property of the reader and is not required of a decoder. A frame with more than one `messageId` key is malformed: a decoder that can see the duplicate (the C# and Rust decoders) refuses it; a decoder that collapses the map (Python, TypeScript, Kotlin) judges the value it ends up with, the last key, and refuses if that value is invalid, so no decoder echoes an invalid id (vectors `admittedMessageIdDuplicates` declare each policy and every runner asserts its own). A decoder refuses an invalid id the way it refuses an empty or ill-typed one; the rule is for `write_admitted` only and `error.v1` is unchanged.
**Where a received `messageId` decides, and what that relies on.** Beyond echoing it, a received `messageId` is used in exactly two places. On a server, the remembered-write key `(actor, messageId)` with the content hash, the per-session limit and the replay of rule 5 are taken from the id of the write frame the server itself parsed. On a client, the write tracker attributes a `write_admitted` or `error.v1` to a write by equality with the id of a write that client itself sent (a frame naming an id not in flight settles nothing), and attributes an id-less error by the count of unanswered writes, not by any id. Both rely on the receiver being the only parser of that frame: a decoder's `messageId` on a frame that carries more than one `messageId` key is not trusted for any authorization, dedupe, replay or ordering decision, and the map-collapsing runtimes (Python, TypeScript, Kotlin, and C#'s typed deserializer on write frames) cannot see the duplicate. If a frame is ever parsed by two runtimes in a chain (a relay, proxy or forwarder that re-reads it), or a consumer needs `messageId` for authorization, dedupe, replay or ordering beyond echo and equality with a self-sent id, all runtimes must first refuse a duplicate `messageId` key (a spec change plus byte-level duplicate detection in the runtimes that decode to a map) before that use lands. Today Rust refuses a repeated key on write frames and on `write_admitted` (serde's `duplicate field messageId`, and `refuse_duplicate_keys` for the raw-list frames such as `document_put_raw`), though its `readable_message_id` names the first key when it names an unreadable frame; C# does not on write frames (a duplicate-key write is deduped on its last id, while the id it names in a refusal for an unreadable frame is read from the first key), and the Python server (`cultmesh_py/server.py`) only echoes the id while the TypeScript and Kotlin code matches it by equality with a request it sent.
**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id.
Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders.
## Capability: opting in
A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. A server advertises the two frames in its hello only if it implements the receipt rules end to end.
**The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in.
## Server rules
1. **One answer.** For every put or delete on a receipt-capable connection the server sends exactly one of `write_admitted` or `error.v1`, carrying that write's `messageId`. Never both, never two different answers.
2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission.
3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read.
4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer.
5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (see Remembered-write limits; a server never forgets an id it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.**
- Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice.
- Same key, same content hash, answer was a refusal: the same refusal again.
- **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged.
- **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically.
- **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests.
- **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table.
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. A put's `storedAt` is the exception to "present and not blank is a name": it must be a stamp, so a non-blank `storedAt` that is no stamp (`tests/vectors/stored-at-stamp`) is refused `message_invalid` with `details.field` `"document"`, after the write authority has decided (an ungranted peer is told `write_not_authorized` first), and nothing is stored or minted; only a blank `storedAt` is refused as unreadable with field `"message"`, before the authority. The non-White_Space code point rows therefore name something in every covered field except `storedAt`. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case.
- **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`.
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1).
- Past the session (or a server's longer retention, if it keeps one) a resend is a new write.
6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission.
7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither.
## Client rules: three outcomes
A write settles as exactly one of:
- **admitted**: a `write_admitted` whose `messageId` is the write's.
- **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error.
- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered; a write registered after the session ended settles this way when it is sent) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other.
**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. It ends when the write's age reaches it: an answer arriving at exactly that instant is late. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window.
**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. A resend on the same session with the same id and content is harmless on a receipt-capable server (rule 5); across a reconnect the caller follows the idempotence rule above.
**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing.
**Batches can commit partially.** A batch is N independent writes with no atomicity: some members may be admitted while others are refused or unanswered, and nothing is rolled back. The client reports each member's outcome and a batch result is only their set: admitted if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). The caller owns retry: it resends exactly the members that are not admitted, with their original ids.
## Changes to what #55 to #58 shipped
Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`. The Idunn presence publisher is send-only by its own function (`sendPresenceDocument`, not the `sendOnly` option): the same `sent` result, plus a rejection if a refusal naming the put (or naming nothing) arrives by the transport ack. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission.
## Vectors
`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `admittedMessageId` (the `write_admitted` id rule above: code-point lists, each marked refuse or accept; a lone surrogate, and a frame that repeats the key, are carried as whole frames in `wireHex`, because a lone surrogate is not valid UTF-8 and only some runtimes' strings can hold it; `write-admitted-message-id-ranges.json` lists every range of the embedded tables, and each runtime probes the first and last point of each range and the points either side), `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7.
## Conformance status
| Server class | Advertises both frames | Server-wide memory budget (MAY, Remembered-write limits) |
|---|---|---|
| `CultNetDatabaseServer` | yes | not implemented: only the per-session limit of 1024 applies |
| Shard write forwarder | yes | not implemented: only the per-session limit of 1024 applies |
| Rust RUDP document server | yes | implemented (32 MiB default, `max_remembered_write_bytes`) |
| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | n/a |
Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts.
Cross-runtime check: `packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts` (`npm run test:interop` in `packages/cultmesh-ts`) runs the TypeScript client (`WriteTracker`, the decoder, the one-shot writer) against the real Rust RUDP document server and the real `CultNetDatabaseServer` over loopback, one scenario set against both. Known gap it records as a todo: the Rust RUDP document server refuses every delete (`write_not_authorized: the server does not take deletes`), where this spec has an admitted delete receipted like a put.
## Conformance follow-ups (not in this PR)
C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog.

View file

@ -1,441 +0,0 @@
# C2a land map
What C2a (`hands/variants-c2a`, a5c7954e) must still prove to merge to main, measured
on 2026-10-22 against main 7a8933b0 and the 2.0.0 release candidate
`hands/cultlib-unity-release-2.0.0` (5b5d8573, which carries f91e30ed). The land cut
is the merge plus the gate. Everything else is classified below. Governing ruling:
`cultlib-gaps:ruling:release-now-c2a-bumps-format`.
## Cut sequence to main (2026-10-27, branch head 7c7cc799)
Landed since the facts below: `land-c2a` (h1), `land-c2a-merge-api-docs` (h1-h6), the 2.0.1 and
cultcache-rs 0.3.1 merges (5baa02fa, f54472e5), `land-c2a-gate` r4 (h1), `store-gate-one-owner`
(h1-h7, Soul s6) and `restamp-on-write` (h1-h2, 8ee66714..7c7cc799, Soul s2 holds). One static `Owns`
decides ownership for cache and cacheless writers, and the High finding
`cut-land-c2a-gate.s1.reflist-record-read-only-at-head` is Fixed by 7c7cc799. origin/main is 73e684b1:
21 commits past the merge base 02cf04b5, all docs and research (15 files, +1879, none under src/,
tests/ or packages/, none on a path the branch changed). What stands between the branch and main, in
order:
1. **claimant-one-owner** (spec r1 at 7c7cc799, Hands then Soul). Restamp's Soul s2 left four Lows
on the landing path. Which entry reads a persisted entry is chosen twice (`Claimant` at
CultCache.cs:703-750, `ClaimantEntry` at :797-799), and so a cacheless writer handed registry {A,B}
overwrites a record that a cache with {A,B} holds read-only (probe X4). The cut keeps one `Claimant`
precedence over a by-id/by-name lookup that the registry or a single entry supplies. A cacheless
writer with a registry then takes its claimant from that registry. The cut also pins the
unpublished-id case and fixes the stale test comment. It closes `claimant-selection-duplicated`,
`cacheless-inverts-registry-claimant`, `unpublished-id-guard-unpinned` and `stale-flag-copy-comment`.
It is kept apart from the gate so that Soul can falsify it alone. Its files (CultCache.cs, the
persistence contract, RestampOnWriteTests.cs) are disjoint from the gate's, so it can run before or
beside step 2. The second cut to land rebases.
2. **land-c2a-gate** (spec r7 at 7c7cc799, Hands h2 then Soul). The ref-list gate does the same write
at 2.0.1 and at head and compares schema ids. The committed vectors are pinned byte for byte under a
fixed clock, and every stage counts its cases. r7 drops r6's head-only restamp assertions, which
`RestampOnWriteTests.TheCommitted1060VectorsListRecordIsWrittenUnderTheHoldersOwnId` already pins.
It also drops the red-at-193cf1c3 run, because restamp is measured. The cut closes
`single-vector-hand-edit-passes`, `gate-green-with-no-refusal-probes` and follow-up
`gate-reads-1x-written-store`.
3. **Ledger** (Self, no Hands; can run now, beside steps 1 and 2). Each of these is in force though
its fix has landed:
- The seven `cut-land-c2a.s1.*` findings. `land-c2a-merge-api-docs` carried their pins (source reads
2026-10-27: GameCult.Caching grants InternalsVisibleTo only to `.Tests` and `.MessagePack`, the
residual being follow-up `caching-messagepack-ivt`; PreCut2StoreFormatTests pins "with a suffix
of"). Close each one against its commit, or defer it. `stale-unity-plugin-dlls` defers to the
Unity 3.0.0 release cut (the plugins are 2.0.1 builds, 1eeb1a01).
- `cut-c2a-write-set.s1.untouched-dropped-slot-resurrects` and follow-up `slot-reuse-contract-doc`.
Ruling `slot-reuse-documented` decided the behaviour, and the paragraph is in
cultcache-schema-compatibility.md:64-66.
- `cut-restamp-on-write.s1.websockets-tests-unreported`, Fixed by h2 (s2 promise
`websockets-tests-reported` holds).
- Follow-up `gate-bound-test-tighten`, carried by restamp r2 (50362447, a768b821). Close it if
restamp's Soul passes measured it; otherwise route it.
- After steps 1 and 2 hold: the four s2 Lows, the two remaining `cut-land-c2a-gate.s1` Lows and
`gate-reads-1x-written-store`.
4. **Consumer pins** (ruling `unpinned-consumers-at-land-3`, follow-up `consumer-pins-before-land`).
Brokkr, Aquarium, Ymir, AquaSynth and Mimir have no `CultLibRevision`/`VerifyCultLibRevision` guard,
and each builds against the sibling `..\CultLib` checkout. In its own repo, each adopts Aetheria's
guard at the 2.0.0 tag, with one Hands per repo, before the merge reaches main. The pins touch no
CultLib file, so they can start now and run in parallel with steps 1-3. Eve and Gjallar get no pin
(ruled). The step is done when each repo's guard fails a build against a CultLib checkout at
another revision and passes at the tag, and the follow-up is resolved.
5. **Main into the branch, then full verification.** After steps 1, 2 and 4, `git merge origin/main`
into `hands/variants-c2a`. It should be clean, because main's commits since 02cf04b5 are docs and
research only. Re-check that before merging (`git diff --stat 02cf04b5 origin/main -- src tests
packages unity`). Then run every suite at the merged head on Yggdrasil: Caching, Mesh, Networking,
Geometry, WebSockets, ConsumerBuild, InteropPeer, cultcache-ts/-rs/-py, cultnet-ts and cultmesh-ts.
Run `old-reader-refusal.sh gate` for csharp, typescript, python and rust. If main gained code by then,
a Soul pass covers the merge.
6. **Land.** The land is `git merge --no-ff`, never a squash, so the SHAs that every report and verdict cites stay reachable from main (skip the branch's non-building commits with `git bisect --first-parent`). Steps 1 and 2 closed at 723b7996 (claimant-one-owner, land-c2a-gate); step 5 closed at the merge commit 3157d4b3 (Soul `cut-land-c2a-main.s1` holds). Step 3 closed except `cut-land-c2a.s1.stale-unity-plugin-dlls` and `cut-land-c2a-gate.s2.mutation-table-reads-gate-as-zero`, which wait on the 3.0.0 release cut and the Eureka tools respectively; step 4 closed with `consumer-pins-before-land` (pins since 2026-10-22). Run `git merge --no-ff hands/variants-c2a` onto main, with an empty diff against the
verified head. There is no tag: tags, the Unity plugin rebuild and `declared-range-gate-own-version`
belong to the 3.0.0 release cut. Open follow-ups `c2a-load-slower` (load 2 to 2.3 times slower than
main on a synthetic Aetheria catalog) and `drift-write-ownership-leaves` do not say "before land".
`c2a-load-slower` still needs a reason or a fix before the 3.0.0 release, under
`cultlib-gaps:ruling:database-shaped-built-for-load`.
## Facts
**1. The merge.** A trial `git merge origin/main` on a5c7954e gives 12 conflicted files and
33 hunks of about 950 marker lines (CultCache.cs 17 hunks/196; cult-cache.test.ts 337;
single-file-messagepack-backing-store.ts 4/106; rs lib.rs 2/51; VariantTests 51; three
CHANGELOG add/adds; store-composition contract 32; Directory store 14; InteropPeer 21).
Main moved 4.8k lines in the cache runtimes since base 1a12bc2d. Three semantic collisions
are not textual:
- *Durable record identity has two owners.* Main added `CultStoredDocument.PersistedSchemaId`
and `CacheBackingStore.MarkWritten` (73d438db, afd799ba, 3f6dda8b). Conditional commit
compares the persisted schema id plus storedAt. C2a already owns this as
`StoredSchemaId` + `StoredVersion` (SHA-256 of the stored bytes, which names the schema
id and storedAt) set by `CultCache.Wrote`. After the merge, 9 references in
ConditionalCommitTests and the contract name main's owner. The version is the owner.
PersistedSchemaId and MarkWritten are deleted, and main's +309 test lines are ported as
pins against StoredVersion. 2.0.0 ships MarkWritten as protected API, and C2a also
removes `SerializeSnapshot`/`DeserializeSnapshot` overrides and changes `ConditionsHold`.
C2a is therefore a major release (3.0.0), which semver forces.
- *TS store commit moved.* Main's `store-file.ts` (0e6452c9, 0010ee92; 391 lines) now owns
commit, replace and the `<path>.lock`. C2a's v3 read and keep-v3-on-rewrite
(`STORE_FORMAT_ELEMENT_IDS`) lives in the old store file and must move into store-file.ts.
- *Rust decode door.* Main's cultcache-rs 0.3.0 grown decoder (`decode_stack`, fc630d3b)
must sit under C2a's `readable_store_format` and v3 `decode_store_file`.
**2. The gate already holds; it needs a test, not a format change.** C2a stamps
`cultcache.store.v3` (single file) or `...v5.directory-content-addressed-pages` exactly when
a record holds an id (`HeaderFor`, CultCache.cs:3543). It also marks when a copied record
decodes holding one (`CopiedHoldIds`, :4285). The 2.0 C# reader accepts only v1/v2
(CultDocumentMessagePackSerialization.cs:229) and directory v4 (Directory store :452).
Main's TS, Rust and Python readers accept only v1 (store-format.ts `isV1Snapshot`, lib.rs:1274,
stores.py:192). Probe on Yggdrasil at 5b5d8573 plus C2a's `v3-base.msgpack` (job
`gate1.log`): 4/4 pass. The single file is refused naming v3, and a later flush leaves it
byte-identical. A v5-stamped directory store is refused naming v5, and its 4 files are
byte-identical after a later add and flush. **The pin:** C2a already ships
`tests/vectors/document-variants-c2a/old-reader-refusal.sh`, which stages an old revision
plus probes on a scratch ref and runs them per runtime. The land cut sets its
`OLD_READER_REV` default to the tag `cultlib-unity-v2.0.0`. It also adds the never-rewritten
assertion and the v5 directory case to the C# probe, and runs all four runtimes at the
tag. If the tag is absent, the land waits for it. The tag, not a package, is the pin: the tag's
tree holds every runtime's released reader.
*Outside C2a's reach (release session):* in 2.0, `CultMesh.WriteSingleFileDocument`/
`WriteSingleFileDocumentPayload` (CultMeshSingleFileDocuments.cs:211-245 at 7a8933b0) build
a fresh one-record v1 snapshot and replace the file without reading it. A 2.0 Mesh write
to a C2a v3 path therefore discards it whole, ids and other records included, and no header
gate can stop a writer that never reads. C2a's version reads under the lock and refuses.
Fix it in 2.0.0 before tagging, or record it.
**5. Consumers.** Aetheria pins a CultLib revision (`VerifyCultLibRevision`) and Unity tags.
Ghostlight, Eve's Rust crates, Huginn and the Brokkr daemon pin cultcache-rs by git rev.
None of them moves when main moves. Two C# consumers build against an unpinned sibling
`..\CultLib` checkout: Eve `GameCult.Eve.Surface.csproj:16` and Brokkr's three csprojs.
Eve.Surface has `[MessagePackObject]` element arrays with no `[CultElementId]`
(`EveAssetCatalogEntry[]`, `EveEntitySoaBuffer[]`, `EveInputActionDocument[]`,
`IReadOnlyList<EveCommandTemplate>`, 0 ids in the repo), so C2a registration refuses its
documents as soon as the checkout pulls the land. Grep finds no object lists in Brokkr's
`[CultDocument]` files (not build-verified). Removed C2a API (`Options`, `AsSchemaAlias`) is
unused by Eve and Brokkr. Fork: `variants:question:unpinned-consumers-at-land`.
## Classification
| Item | Class | Why |
|---|---|---|
| c2a-format-version-gate | **a** | The gate test above. No new header: v3/v5 already refuse. |
| slot-reuse-contract-doc | **a** | The ruled consequence of the write set C2a lands. One paragraph in cultcache-schema-compatibility.md, absent at a5c7954e (grep). |
| foreign-ids-under-v1-header | **b** | Obsoleted by fb065f0d (write-time `CopiedHoldIds`, after the finding) for every C# writer with the type. The residual (non-C# or opaque writers) is what `non-csharp-writers-mark-ids-2` ruled to carry, and the contract states it (persistence-format.md:434-439). The gate changes neither side, so no fork remains. Resolve as Superseded by that ruling. |
| custom-resolver-element-ids | **b** | Fixed by b091d21f (19:52Z, after the 18:27Z follow-up): `ListElementsClaimedByAResolverAreRefused`, `AResolverClaimingTheListTypeOfAnObjectListIsRefused`. s6 holds on the claim loop. |
| codec-door-proven-mismatch | **b** | Done in h6 57fdf70c (s6 `only-proven-mismatch` Holds; Geometry 76/0). |
| consumer-element-id-sweep-r2 | **c** | Each repo's sweep is its own work (q3). Only the unpinned trackers matter at land, and that is the question below. Aetheria bumps a pin on purpose (C4). |
| schema-discovery-holder-recogniser | **c** | `TryGetDictionaryRefTypes` is identical on main (CultCache.cs:1288). It describes record-ref dictionaries and is not on the id path. Changing it moves schema hashes, which is its own epoch event. |
| copy-check-memo | **c** | Performance only (about 3x flush on unmarked stores of 20k records). Correct as is. |
| string-key-field-and-shadow-pins | **c** | No wrong behaviour at 64ec14f9. These are test pins and may ride the land cut if Hands touches the file. |
| declared-range-gate-own-version, range-check-in-ci | **c** | The script exists only on C2a and runs in no CI. It guards a TS publish, not a merge. Fix both before the first TS release from main after the land. |
| s6 Unproven: Unity/IL2CPP identity, other MessagePack versions | **c** | The merge reaches no Unity build. Unity sees C2a only at the 3.0.0 tag, so measure it in the Unity release cut (operator Unity check, C3). |
| multi-store-commit-atomicity, store-replace-cut, reader-refuses-duplicate-keys, soft-flush-all-runtimes, writer-dangling-link-tolerance, unique-index-cut, py-shared-write-lock, rust-lock-open-waits, ts-lock-test-hardening, c0/legacy/directory vectors, culture-test, wire-id-cuts, remaining-variant-cuts, golden-path, paged store | **c** | None says "before land". Each is pre-existing on main or C2a-independent. None lets a released reader strip ids or corrupt a C2a store. |
## The land cut's shape (transcribe once the question is ruled)
- **Base.** Merge main at or after the `cultlib-unity-v2.0.0` tag into `hands/variants-c2a`.
Versions stay main's. CHANGELOGs get Unreleased entries, and the Unity entry says major.
- **Deletes and collapses.** Delete `PersistedSchemaId`, `PersistedIdentity`,
`CultStoredDocument.MarkWritten` and `CacheBackingStore.MarkWritten` (main, about 60 lines),
and the store-composition contract's MarkWritten paragraph. C2a's `StoredVersion`/`Wrote`
is the one owner. In TS, delete C2a's header handling in
single-file-messagepack-backing-store.ts and move it into store-file.ts. In Rust, C2a's v3
decode goes through the grown decoder.
- **Adds.** Retarget old-reader-refusal.sh to the tag. Add the C# probe's never-rewritten
assertion and the v5 directory case (as in gate1.log). Add the slot-reuse paragraph.
Port main's ConditionalCommitTests to the version identity.
- **Verification.** Full suites: Caching, Mesh, Networking, Geometry, WebSockets,
ConsumerBuild, InteropPeer, cultcache-ts/-rs/-py, cultnet-ts and cultmesh-ts, all on
Yggdrasil. Run old-reader-refusal.sh for all four runtimes at the tag, and it must refuse.
Negative greps over the merged tree: `PersistedSchemaId|MarkWritten`, conflict markers.
Mutation: drop the v3/v5 stamp in `HeaderFor` and the gate probe must fail.
- **Estimate.** About 950 conflict lines resolved. Net -60 to -150 from the identity
collapse and TS de-duplication, +80 to +120 tests and docs. One Hands pass of 100-150
calls, then one Soul pass.
- **Precondition (question).** Eve.Surface, and Brokkr if confirmed, are pinned or swept
before the merge reaches main.
## Store gate: attachment is the attach's hold (store-gate-one-owner r2)
Spec `variants:cut_spec:cut-store-gate-one-owner.r2` (base ec94ab98), question
`variants:question:pulled-variant-codec`. It closes Soul s3's
`attach-window-cacheless-write` (Medium) and `codec-loan-pinned-by-reflection` (Low).
**Facts (source reads at ec94ab98 and a probe, 2026-10-25).**
- The window. AddBackingStore holds the cache's gate, then the store's gate through
`InStoreHold`, lends the registry and codec, sets `Loaded`, adds the store to `_stores`,
pulls, and only then sets `store.Cache` (CultCache.cs 2268-2312). `InStoreHold` lets any
call on the same store's gate re-enter it (2825-2831). So a journal the pull's Admit runs
on the attaching thread can call `store.WriteDocument`: `Gate(cacheless)` guesses null,
re-enters the store's gate, finds `Cache == null` and runs the write, and its `finally`
calls `UseRegistry(null, null)` (Soul s3 P1, full1.log lines 49-53). Other threads are not
in the window: they queue on the store's gate and see either the published cache or a
store that the failed attach put back.
- The codec loan has no reader on the attach path. A pulled variant's codec is read only
by `CultStoredDocument.HoldsIds` (543-557). Header decisions read `HoldsIds` only on
staged entries (single-file 4318, 4447 and 4472; directory store 362), and a staged entry
is always the document the cache pushed (`Push` sets `Entries[key] = entry`, 4211-4215).
A single-file `PullAll` returns without loading while the store is dirty (4157-4159), so
a pulled copy is never staged. A refused write unstages its keys before it adopts the
reload. The directory store refuses variants (ruling `q6-directory-and-cultnet-refuse`;
DirectoryMessagePackBackingStore.cs 139-144 and 642-643). Admit re-resolves every pulled
variant with the cache's codec (CultCache.cs 3259). Soul's N4 mutant (the attach lends
no codec) was caught only by the reflection test, and h4's M2 only by a bare-store test
that sets `Loaded` and calls `UseRegistry` by hand.
- Probe (scratch clone `imagination-store-gate-r2/clone`, commits 4663934e and c099b9e8 on
ec94ab98; Yggdrasil t1.log, t2.log and t3.log; 2026-10-25 around 20:50 CEST). The
probe did three things. `InStoreHold` refuses a cacheless call that re-enters the same
store's gate. The `_codec` field and the two-argument `UseRegistry` are deleted. The pull
builds variants with a null codec. Results:
- Mesh is 342/0/4.
- In the Caching suite, the only failures were two probe tests, both from authoring
errors: a key `AddAsync` minted, and an element id that was not 12 hex characters.
No existing test failed, `UserCodeInACachelessCallMayCallItsOwnStore` included.
- Soul's P1 as a test: the journal's write is refused with the new message. The attach
keeps its registry and the file holds only `a`. After the attach, a cache write, a
flush and `store.PullAll()` all succeed.
- A cacheless write nested in another on the same store is refused, the outer write
fails, and no file is written.
- Through a real attach, a store holding a name-override variant flushes an unrelated
commit with a `cultcache.store.v2` header. With no codec loan, the pulled copy does not
mark the store.
- Mutant `return cacheless` changed to `return false`, which is ec94ab98's behaviour,
fails both new gate tests. In that mutant the nested write succeeds and the file exists.
**Rationale.**
- Attachment has one fact again. While the attach runs, the store's gate is the attach's
hold, and `Cache` is published when that hold ends. A cacheless write is a write the
store owns outright, so it must be the outermost hold of its store's gate. One more
argument to the existing owner of per-thread gate facts (`InStoreHold`) refuses it. No
field mirrors `Cache`, and `Loaded` and `_registry` are not read. Precedent:
`ConcurrentHashMap.computeIfAbsent` refuses a mapping function that updates the same
map with `IllegalStateException("Recursive update")` since Java 9. That is from
memory, not fetched this pass; the 2026-10-23 prior-art note covers the documented
restriction in its counter-positions. Non-cacheless store calls from user code still
re-enter: `UserCodeInACachelessCallMayCallItsOwnStore` stays green.
- The late publish stays. It is what bounds `Gate` at two passes (s2's finding). Publishing
early would bring back the failure-rate-bounded retry.
- The codec loan goes: it is a value nothing on the attach path reads. A pulled variant
carries no codec and counts as holding ids (the documented conservative answer), and
the cache's resolved copy is what any header decision sees. This reverses the codec
half of Defaulted ruling `late-publish-needs-attach-codec`, so it goes to Self as
`variants:question:pulled-variant-codec`. The spec is written for the recommended option.
- Scar check: `git log -S` over CultCache.cs for `reentrant`, `outermost` and `_attaching`
finds nothing. `Cache?.Codec` appears in 1468a83a (h4) and 619e089f only. No earlier
re-entry refusal was built and deleted.
- Behaviour change, stated in the CHANGELOG: a cacheless write made from user code that
another cacheless write of the same store runs used to succeed, and its `finally` took
the outer call's registry. It is now refused.
## Store gate: Cache is the one attachment fact (store-gate-one-owner r3)
Spec `variants:cut_spec:cut-store-gate-one-owner.r3` (base 0fa836cb), superseding r2. It
closes Soul s4's `attach-window-held-store-writes` (Medium), `cacheless-refusal-order-unpinned`
(Low) and `interop-consumer-suite-unrun` (Low). This is the third time the attach window has
beaten the rule (s3 from the cacheless side, s4 from the held side), so r3 moves the
decision instead of patching the next entry point.
**Facts: every entry point that reads or decides attachment, at 0fa836cb.** Source reads of
CultCache.cs, DirectoryMessagePackBackingStore.cs and CultMeshSingleFileDocuments.cs. The
window column is a journal on the attaching thread, through a real attach of a store holding
`a`. Probe: scratch clone `imagination-store-gate-r3/clone`, commit b2330b6d (test R3Probes),
Yggdrasil `probe-base.log`, 2026-10-25 around 23:00 CEST. "Disagree" means that after the
attach, a commit of `later` and a flush, the attaching cache holds `a,later` and a fresh cache
over the same file reads something else.
| Entry point (line) | Fact it reads | In the attach window at 0fa836cb |
| --- | --- | --- |
| Single-file `PullAll` (4156) | `Gate`: `Cache` guessed unlocked, then re-read under the re-entered store gate | Runs as detached; its `Loaded` re-enters the cache and is refused as a journal write. Agrees. |
| Single-file `Push` (4212) | same | Runs as detached and stages `b`. Disagree: fresh reads `a,b,later`. |
| Single-file `Delete` (4223) | same | Runs as detached and stages a removal the cache never made (dirty after the attach). |
| Single-file `PushAll` (4239), and `Dispose` (3722) through it | same, then in-body `Cache == null` (4531, 4543, 4588, 4603, 4611, 4661) | Runs as a cacheless writer. Push then PushAll writes `b` to the file inside the attach (disagree). Delete then PushAll is refused with the cacheless writer's text. On a variant store it is refused as "a writer with no CultCache" (Soul s4). |
| Single-file `CommitBatch` (4256) | same | Runs as detached and commits `b`. Disagree. |
| Single-file `JudgeChecks` (4568), `RefuseRemovalOfUndeclared` and `StageChecks` (4558, 3915) | same | Run as detached. Agree in the probe, but under the wrong gate's rules. |
| Single-file `ReadStoreSnapshot` (4687) | same | Runs as detached and reads the file. |
| `WriteRecord` (4342), `WriteDocument` (4366) | `InStoreHold`'s per-thread hold (cacheless re-entry), before `Cache` | Refused with the re-entry text (h5). Agree. |
| Directory `PullAll` (48) | `Gate`, as above | Refused as a journal write. Agree. |
| Directory `Push` (147), `Delete` (160), `CommitBatch` (172) | same | Run as detached. Disagree: fresh reads `a,b,later` after Push or CommitBatch, and `later` after Delete. |
| Directory `PushAll` (235) | same | Runs as detached. Push then PushAll and Delete then PushAll disagree. |
| Directory `JudgeChecks` (269), `RefuseRemovalOfUndeclared` (259) | same | Run as detached. Agree. |
| In-body reads: `StoredAtOver` clock (4028), refusal text and `DurableAt` (4531, 4543), declares and cacheless refusal (4588, 4603, 4611), `Wrote` (4661) | `Cache`, under the gate `Gate` chose | On the attaching thread they read null while the thread holds the cache's gate, so a held call runs as a cacheless writer. |
| `AddBackingStore` (2259-2314) | writes `Cache` under both gates, after the pull | The window's author: the store is attached for the pull (registry lent, `Loaded` set, in `_stores`) while `Cache` is null. |
| Mesh `CultMeshSingleFileDocuments` writes and reads (99, 143, 174, 226) | none of the attached store's | `StoreAt` (239) builds a new store per call, so they never touch an attached store object. A file it shares with an attached store is the file lock's and the write set's business. |
| `IsDirty`, `HasStagedChecks`, `ForeignRecords`, `LastSchemaMigrationReports`, `HeaderFor`, `HeaderForWrite` | none | Ungated values or static decisions. They read no attachment fact. |
So during an attach, cacheless entry points decide attachment from the hold, and every held
entry point, plus every in-body read, decides it from `Cache`, which is still null. These are
two facts.
**Probe of the chosen shape** (commit 4695390c on b2330b6d; `proto-full.log`). `AddBackingStore`
publishes `Cache` before the pull and takes it back in the same hold if the pull fails. `Gate`
becomes straight-line code: guess, then the store's gate, where `Cache` is settled, then the
settled cache's gate. Results:
- All 21 probe cases are refused. The cacheless writes get the re-entry text and every other
case gets "A journal entered the cache it journals". After the flush, the attaching cache
and a fresh cache both read `a,later`.
- Caching: the only failures are the two tests that pin the late publish:
`AnAttachLendsTheRegistryAndPublishesTheCacheAfterThePull` (asserts no cache during the
pull) and `ACallWhoseGateChangesTwiceUnderItThrowsInsteadOfLooping` (asserts the old message
text; the tampering reader still throws). The churn, queued-reader and failed-attach tests
pass unchanged.
- Mesh and Networking are green.
- The cultcache-ts suite, which builds and drives the C# InteropPeer, passes on this shape:
175 tests, 171 pass, 0 fail, 4 skipped. The C#, Rust and Python interop tests ran, the C#
edit-in-place one included (`proto-ts2.log`, commit f5f02053). Recipe `clone/r3ts.sh`:
image node:24-bookworm, apt python3-msgpack, dotnet-install for 10.0, rustup minimal.
**Shapes weighed (rationale).**
- (a) Publish `Cache` before the pull, as a616431e did. Chosen. What s2 found was not that an
early publish is unbounded. It was that `Gate` re-read `Cache` *unlocked* after a stale
guess, so a churn of failing attaches could hand it a new stale guess every pass. The fix is
where `Gate` reads on its second step. Under the store's gate, `Cache` is settled: an attach
publishes and takes it back inside one hold of that gate, and no other thread can hold that
gate during the attach. A settled cache is permanent, because there is no detach. So `Gate`
needs at most three acquisitions, with no loop and no counter: the guess's gate, the store's
gate, and then the settled cache's gate, which cannot miss. This deletes a fact rather than
reconciling two. During the attach the store *is* attached, so every entry point, cacheless
or held, and every in-body read sees one answer. A journal's call is then refused by the rule
journals already have (AddJournal: "any hold on this cache while a journal runs throws"),
exactly as it is after the attach. Prior art: `ConcurrentHashMap.computeIfAbsent` installs a
ReservationNode in the bin *before* running the mapping function, so a recursive update on
the same thread finds the owner and throws, and on failure the reservation is removed (see
`docs/research/2026-10-23-store-lock-user-code-prior-art.md` item 4 on main: the re-check
after locking, and the ReservationNode on the empty-bin path). The validate-after-acquire in
`Gate` is the same item.
- (b) Keep the late publish and make the attach's hold the one fact for every entry point.
Rejected. `InStoreHold` knows that this thread holds the store's gate, not *whose* hold it is.
Refusing every same-store re-entry kills `UserCodeInACachelessCallMayCallItsOwnStore` (r2's
R5), so (b) needs an "attaching" marker beside `Cache`. That is the second fact that s3 and
s4 rule out, plus every in-body `Cache == null` read would need to consult it as well.
- (c) Run no user code on the attaching thread under the store's hold during a pull: defer the
pull's journals to after the hold (open calls). Rejected as insufficient on its own. Getters
and constructors still run during the pull's decode, so the window stays open to any user
code that is not a journal. It also moves journal timing, which is a documented contract
("right after an admission is applied").
- Scar check: `git log -S` over CultCache.cs finds `store.Cache = null` and `MaxStaleGuesses`
only in ba658965 and 1468a83a. The early publish was not removed for a correctness reason of
its own: 1468a83a removed it to bound `Gate` and to lend the codec, and the codec loan is gone
(`pulled-variant-codec`). No settled read under the store's gate was built before. The
precedent recipe (no root filter) finds no same decision.
- Not a product fork. A journal's store call during an attach now throws, as it already does
after the attach. Cacheless writes on the attaching thread stay refused, with the same text.
Getters and constructors during the pull see an attached store, as they do during any held
call. It changes the late-publish clause of Defaulted ruling `variants:ruling:pulled-variant-codec`,
so Self defaults a superseding ruling (recommended label `cache-published-for-the-pull`)
before dispatch.
- Residual, not in r3. `Push`, `Delete`, `PushAll` and `CommitBatch` are public, so user code
that is not a journal (another thread, or a getter during any held call) can stage records
behind an attached store's cache. Soul s4 shows this with `other-thread-push`: file `a,b`,
cache `a`. That is true before, during and after an attach, so it is not the window's
two-fact problem. Closing it changes public API (a store's writes taken only from its
cache), so it is a follow-up and an operator question for Self, not part of this cut.
## Body facts: claimant-one-owner r2 (Imagination, 2026-10-27, base fc74cf6f)
Why a revision: Soul beat the same rule three times (s1 claimant choice, s2 compatible ids, s3 shape). Each time a
cacheless write lent a registry let the registry's entry for the written schema id decide ownership beside the entry
the caller passed. Two descriptions of one type reached `Owns`; each fix (h2 schema-id compare, h3 subset of compatible
ids) compared one more facet of the two and left the rest open. r2 removes the second description from the lookup.
### Where the two descriptions met (source read, fc74cf6f)
- `OwnsOnDisk` cacheless arm (`CultCache.cs:4725-4740`): with a lent registry it called
`lent.Owns(id, catalog, declared, written)`, whose lookup is the registry's `_indexes` and whose `entryOf` is
`descriptor.ToCatalogEntry()` (`:824-825`). The claimant for the written schema id was the registry's descriptor,
so `ReadsWhole(persisted, claimantEntry)` (`:848`) judged shape against the registry's entry, and the claimant
precedence (`:756-806`) walked the registry's compatible ids. `written` (`:4734-4736`) then compared the two entries
by schema id plus a subset of compatible ids.
- `WriteDocument` (`:4495-4524`) takes its entry from the registry (`resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry()`),
so its two descriptions are always identical. Only `WriteRecord(record, catalogEntry, holdsIds, registry)` (`:4471`) can
pass an entry that disagrees with the registry's entry for the same id.
- `CopiedHoldIds` (`:4600-4630`) also reads `AttachedRegistry`, to decide the v3 header from copied records. It is a
positive-only header decision, not ownership, and out of this cut.
### Consumer audit (2026-10-27)
- `WriteRecord` and `WriteDocument` are new in the Unreleased 3.0.0 entry; `main` has neither (`git show main:src/GameCult.Caching/CultCache.cs`).
- Per-repo `rg` over every git repo directly under `F:\Projects` for `.WriteRecord(`, `.WriteDocument(`, `WriteSingleFileDocument`
(Library, obj, bin, PackageCache excluded): hits only in CultLib and in vendored CultLib copies under Heimdall and StreamPixels
(`vendor\CultLib`, older pins without these methods). No consumer outside CultLib calls either method.
- Inside CultLib at fc74cf6f: Mesh `WriteSingleFileDocument` calls `WriteDocument(key, document, storedAt, registry)`;
Mesh's raw-payload path calls `WriteRecord(..., registry: null)` (`CultMeshSingleFileDocuments.cs:129, 226`). The only
`WriteRecord` calls with a registry are tests: `CachelessSingleFileTests.cs:453, 660` (a registry `{IdDeck}` that does
not hold the written entry `One` at all, used for the copied-record header), `RestampOnWriteTests.cs:595, 599`, and
Soul's probes. Packages: no TS, Python or Rust counterpart takes a registry (cultnet-rs `write_record` is an example helper).
- Consequence: the registry's documented role in `WriteRecord` is to describe the other records in the store. A shape
that derives the written entry from the registry (or refuses an entry the registry does not hold) breaks
`CachelessSingleFileTests.cs:660` and removes that role.
### Candidate shapes weighed
| Shape | One description? | Public API | Kills s2/s3 | Cost |
|---|---|---|---|---|
| A. Lent registry's entry decides; refuse a passed entry that disagrees | yes, after a door check | signature kept; new refusal | yes | an equality check over a redundant parameter; breaks the registry-describes-others use (`:660`) when the registry lacks the written type unless it falls back to the passed entry, which is two descriptions again |
| B. Passed entry is the only description of its schema id; the registry supplies other types only | yes, by construction | signature kept; doc only | yes (probed) | one lookup class gains the registry; one registry overload deleted |
| C. Overloads: `WriteRecord(record, catalogEntry, holdsIds)` and `WriteRecord(record, registry, holdsIds)` | yes, by type | changes a 3.0.0 signature | yes | loses the registry-describes-others use |
| D. Delete `WriteRecord`'s registry parameter | yes | changes a 3.0.0 signature | yes | loses the copied-record header decision for raw writes |
Picked B. It is the only shape that keeps every documented use of the registry, needs no public signature change,
and deletes the comparison instead of extending it. It is ordinary scoping (an inner binding shadows the outer one
for its name), applied to schema ids.
### Probes (Starfire, local filtered runs, 2026-10-27)
Scratch clone of fc74cf6f at `<scratchpad>/imagination-claimant-r2/clone`, Soul's `SoulClaimantProbe.cs`,
`SoulClaimant2Probe.cs`, `SoulClaimant3Probe.cs` copied into `tests/GameCult.Caching.Tests`.
Base fc74cf6f (`probe-base.log`):
- P7 (registry {N lists own, A, B}; passed entry N with the list varied; record k under O, named like N): lent R / no
registry C for rows b, b2, e, g, h, k, l, l2; lent C for a, c, d, f, i, j. Confirms `subset-refuses-own-drift`.
- P9 (registry {NW: Name, Value, Colour}; passed NW minus Colour): lent C (restamped, Colour lost), no registry R.
Confirms `lent-shape-widens-passed-entry`.
- P6 (bare X1 entry, registry {X1} listing OX, record under OX): lent R, no registry R, cache C. h3 closed the id axis.
- P4: 12 states x 8 registries, violations=0. P1d: a cache {N,L} commits N over L's listed old id (`cache-replaces-other-types-record`).
Prototype B (scratch commit 349ea3f3 on the clone, not for landing; `probe-protoB2.log`): `CatalogEntryLookup` takes
the written entries and an optional registry and drops every registry descriptor whose schema id a written entry has;
the registry's `Owns(schemaId, catalog, declared, mayClaim)` overload is deleted; the cacheless arm always asks
`CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes, AttachedRegistry), entry => entry,
claimant => writes has claimant's schema id)`.
- P7: lent == no registry on all 14 rows (all C).
- P9: lent R, no registry R, wide entry C.
- P6: unchanged (R, R, C). P4: identical table to base, violations=0. P1a-f, P2a-d: identical to base.
- `RestampOnWriteTests`, `CachelessSingleFileTests` and the probes: 139/139 passed locally.
- Yggdrasil, 349ea3f3, `ygg-protoB-full.log`: GameCult.Caching.Tests 1180 passed / 0 failed / 2 skipped;
GameCult.Mesh.Tests 342 / 0 / 4.
Negative greps, each tested at fc74cf6f (each matches there, and none may match after the cut, src and tests):
`claimant.CompatibleSchemaIds.All(` (1, the subset), `lent.Owns(` (1), `Func<CultSchemaCatalogEntry, bool> mayClaim) =>`
(1, the deleted overload), `CatalogEntryLookup(writes)` (3, one-argument construction). CHANGELOG and docs:
`never widens what the passed entry declares` (1), `with no compatible id the passed entry does not list` (1).
### Not settled by r2
- `cache-replaces-other-types-record` (s2, PreExisting, cache arm): r2 touches only the cacheless lookup, so it does
not settle whether a cache may replace another registered type's record at a shared key.
- Whether a registry-less `WriteDocument` should consult `CultDocumentRegistry.Shared` for claimants at all (s1
`shared-decides-registryless-write`, closed Fixed by documentation in h2). r2 keeps today's answer.
- `variants:finding:cut-restamp-on-write.s2.cacheless-inverts-registry-claimant` is still in force although r1 was
mapped to close it; Self should close or re-triage it after r2's Soul pass.

View file

@ -259,7 +259,7 @@ landed in `ead5260c`, Python in `5618d8dd`, Rust in `c028c511`. TypeScript is no
| Runtime | Owner of the decision (constant and walker) | Doors routed through it |
|---|---|---|
| C# | `CultPayloadDepth.Check` (`src/GameCult.Caching.MessagePack/CultPayloadDepth.cs`), limit `CultMessagePackSecurity.MaximumPayloadDepth`. MessagePack's own graph counter is set to the same value as a backstop only. | Write: `CultDocumentMessagePackSerialization.SerializeUntyped`. Read: `DeserializeUntyped`. The cache store (`SerializePayload`/`DeserializePayload`), the directory store, `CultMesh` and single-file documents all call those two; C# has no separate raw-envelope or prepared-batch door. |
| Rust | `check_record_payload` (private; depth, then no bytes after the first value) over `first_value_end`, and `MAX_PAYLOAD_DEPTH`; `cultcache_rs::check_payload_depth` is the depth-only probe for outside callers (`packages/cultcache-rs/src/lib.rs`). | Read: `decode_payload` (get, list, `validate_envelope`/`put_envelope`, `finish_prepared_entry`). Write: `put`/`prepare_entry`/`prepare_entry_named` reach it through `finish_prepared_entry`, which decodes what it just encoded. Raw envelope: `put_raw_envelope`, the door CultMesh network puts use (`cultmesh-rs` `lib.rs`, two call sites). Prepared batch: `put_prepared_batch` via `require_record_payload`. Load: `pull_all_backing_stores` and `load_soa` run `require_record_payload` on every record, so a store holding a payload past the bound (or with bytes after it) is refused at load, and the cache then refuses every write and removal (`CultCacheLoadRefused`) until a load succeeds. cultnet-rs: every free-form or skippable wire payload decodes through `cultcache_rs::decode_payload` (message decode in `contracts.rs`, `provider_session`, `media_stream_wire`, `replication`, `service_identity`). |
| Rust | `cultcache_rs::check_payload_depth` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-rs/src/lib.rs`). | Read: `decode_payload` (get, list, `validate_envelope`/`put_envelope`, `finish_prepared_entry`). Write: `put`/`prepare_entry`/`prepare_entry_named` reach it through `finish_prepared_entry`, which decodes what it just encoded. Raw envelope: `put_raw_envelope`, the door CultMesh network puts use (`cultmesh-rs` `lib.rs`, two call sites). Prepared batch: `put_prepared_batch` via `require_payload_depth`. cultnet-rs: every free-form or skippable wire payload decodes through `cultcache_rs::decode_payload` (message decode in `contracts.rs`, `provider_session`, `media_stream_wire`, `replication`, `service_identity`). |
| Python | `_refuse_past_depth_limit` and `MAX_PAYLOAD_DEPTH` (`packages/cultcache-py/src/cultcache_py/documents.py`); a msgpack `StackError` on read maps to the same error. | `DocumentDefinition.encode_payload` (write) and `decode_payload` (read). `CultCache.put`, `put_envelope`, `put_envelopes` and load-time hydration go through them; there is no raw-envelope door. cultmesh-py round trips call the same pair. |
- **Doors deliberately not routed.** cultnet-rs `idunn_lifecycle_brake`, `provider_health_contracts` and

View file

@ -31,79 +31,6 @@ copy of the solver and the warm/cold sweep, `suite` a scratch copy of `packages/
conditional commit compares that (ruling `aetheria-release:ruling:expect-fix-in-cultcache`). C# only. The
release that carries it to Aetheria follows question `cultlib-gaps:question:expect-fix-release-lane`; the
Aetheria pin bump is an Aetheria follow-on, not this cut.
9. `aot-record-ref-generator` (section A, AOT cut 1): a consumer with MessagePack's source generator on can declare
`CultRecordRef<T>` members and dictionary keys. Base: the C2a branch head `a768b821`; Hands starts after C2a merges
to main or rebases onto it. Serves `record-ref-breaks-msgpack-generator`, the first cut of
`cs-aot-safe-registration` (operator 2026-10-27: "I'm not a fan of requiring reflection. ... AoT is a nice thing to
support anyway.").
10. Not mapped (AOT cut 2): the generator reaches consumers that reference only `GameCult.Caching.MessagePack` (today its
analyzer is `PrivateAssets="all"`, so they run every document on Reflection.Emit).
11. Not mapped (AOT cut 3): registration descriptors are generated instead of reflected (`BuildDescriptor`,
`DiscoverMembers`, `CultDocumentResolver`, `CultMessagePackSecurity`), and C2a's element-id walk stops reflecting
(A6), so a NativeAOT consumer round-trips a ref-keyed dictionary.
## A. AOT cut 1: CultRecordRef under MessagePack's source generator (cut `aot-record-ref-generator`)
Imagination pass `imagination-aot-cut1`, session `self-2026-10-27-eureka`, pinned to `origin/hands/variants-c2a` at
`a768b821` (C2a's head; merge base with main `02cf04b5`). Starting evidence: `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md`
(main `eb6a8f2c`). Probes: the scratch console from that note, copied to the session scratchpad
`imagination-aot-cut1/aot-probe/` and committed only as dangling commits on top of `a768b821` (`a0f06b2c`, `6d382961`,
`dc6cf9dc`, `99cae039`, `07b89982`), run on Yggdrasil in `mcr.microsoft.com/dotnet/sdk:10.0` (MessagePack 3.1.7, .NET
10.0.12). Logs `job1.log` to `job5.log` beside the probe. Variants: G = `CultRecordRef<T>` member only, H = ref-keyed
`Dictionary<CultRecordRef<T>, float>` only, B = both. Every variant references `MessagePack` directly (generator on).
### Body facts
- A1. The generator does not discover `CultRecordRefFormatter<T>`. For a generic type it does not know, it collects
the type's definition as an object and names the formatter it would generate, `<Type>Formatter<T>`, nested in
`GeneratedMessagePackResolver`. For a type from another assembly it then generates nothing, so the resolver names
`GeneratedMessagePackResolver.GameCult.Caching.CultRecordRefFormatter<T>`, which does not exist (CS0426). The match
with our formatter's name is a coincidence. Evidence (job1): `[ExcludeFormatterFromSourceGeneratedResolver]` on the
formatter, and an `internal` formatter, both still fail G, H and B with the same line. Upstream issue
MessagePack-CSharp#2133 (open, no maintainer reply) reports the same failure for any open-generic type with a
formatter in another package.
- A2. Consumer MessagePack 3.1.12, the newest stable, fails G, H and B the same way (job1). Upgrading does not fix it.
- A3. `[MessagePackFormatter(typeof(CultRecordRefFormatter<>))]` on the `CultRecordRef<T>` struct stops the
generator naming a formatter for it (the mangled line is gone), but the generator then reports `MsgPack006` (error)
in every consumer: it checks `typeof(X<>)`'s interfaces, which an unbound generic does not list. Adding the
non-generic `IMessagePackFormatter` marker to the formatter does not help (job2 `ta2`). With `MsgPack006`
suppressed by the consumer, G, H and B build and run (job2). Rejected: it needs the formatter moved into
`GameCult.Caching`, which then needs the whole `MessagePack` package instead of `MessagePack.Annotations`, and every
generator consumer still has to suppress an error.
- A4. `[assembly: MessagePackAssumedFormattable(typeof(CultRecordRef<>))]` in the consumer assembly fixes G, H and B
with no library change (job2 `base@ASSUMED`). The generator stops collecting `CultRecordRef<T>`; the generated
document formatter asks the resolver, and `CultDocumentResolver` serves the ref. This is the attribute MessagePack
documents for "a formatter added through a custom resolver at runtime". The same attribute as an MSBuild
`AssemblyAttribute` item in the consumer project (`_Parameter1_IsLiteral`), which is what a package's
`buildTransitive` targets injects, does the same (job3 MG, MH, MB). The attribute declared in the library assembly
does not reach the consumer's generator (job4 LIBATTR: CS0426).
- A5. Generated and dynamic formatters write the same bytes. RefDoc (B) payload is `93A172A17081A170CA3F000000` from
the Reflection.Emit fallback with no generator (job3 BASE) and from the generated formatter (job3 MB). The document
formatter is `GeneratedMessagePackResolver+Probe+RefDocFormatter` (`dynamic=False`); the ref formatter is the
library's `CultRecordRefFormatter<PlainDoc>` through `CultDocumentResolver`.
- A6. NativeAOT (`PublishAot`, probe assembly rooted, linux-x64): G round-trips (payload, upsert, flush, reopen) with
payload `92A172A170`, identical to JIT (job4 G0). B fails, in C2a code, not in the generator path: upsert fails in
`CultElementIds.MapEntries`, whose `MakeGenericMethod(key, value)` has no native code for a struct key (job4 B0). With
that walk reading the map through non-generic `IDictionary` instead, upsert passes under AOT and JIT is unchanged
(job5 B1), but the payload and flush still throw `TargetInvocationException` from a second reflective call on the
serialize path. By source read the candidate is `ResolverClaim`'s `GetFormatterMethod.MakeGenericMethod(type)`
(`CultDocumentMessagePackSerialization.cs:84`), not proven. Both are runtime reflection over element-id paths, which is
AOT cut 3's ground, so cut 1 proves AOT on the member shape and leaves the key shape's AOT round trip to cut 3.
- A7. `GameCult.Geometry` already runs the generator (it references `MessagePack` directly) and carries the per-member
workaround on three members (`CultGeometryDocuments.cs:164,239,328`). Under A4 those attributes are redundant.
### Shape the cut lands, and why
`GameCult.Caching.MessagePack` ships `buildTransitive/GameCult.Caching.MessagePack.targets`, which adds the A4 assembly
attribute to every project that consumes the package. NuGet consumers need no line of their own, which is what a
consumer expects from a library whose type breaks the generator. Unity consumers and in-repo `ProjectReference`
consumers do not get `buildTransitive`: Unity consumers write the one attribute line (README), and in-repo consumers
import the targets file, so they exercise the file that ships. The attribute type comes from `MessagePack.Annotations`,
which `GameCult.Caching` already brings, so the injection compiles in a consumer with or without the generator. Wire
bytes do not change: no formatter changes, only which code path asks for it (A5).
Rejected: a type-level formatter attribute (A3); excluding or hiding the formatter (A1); upgrading MessagePack (A2);
declaring the attribute in the library (A4); keeping per-member attributes, which cannot reach dictionary keys.
## B. BoundedLeastSquares stopping

View file

@ -1,88 +0,0 @@
# CultMesh garden path: map
Campaign `cultmesh-garden-path`, stage 1 (server-authoritative garden path for the
orbs .io game). Target: `docs/cultmesh-garden-path-target.md` (typed as
`cultmesh-garden-path:target:r2`). Rulings, questions and follow-ups are documents
in the mind; this page holds the model page, body facts and the stage 1 cut lines.
Written by Imagination (`imagination-gp-0b`, session `self-2026-10-27-eureka`)
against CultLib `d1bf21b3`. Refreshed by `imagination-gp-stage1` (session
`self-2026-10-27c-eureka`) against `51ca81a1`: every fork the page waited on is
ruled, and the cells below carry the rulings.
## Model page
One row per kind. "Store" says where it lives: a CultCache document in the room's
store, or transient per-tick data that never enters a store. Proposed values are
marked as such; they are the design this page offers for the cuts, not landed
behaviour. A cell that waits on an operator question names it.
The governing split, from `state-is-cultcache` and the CultCache scope restraint:
every value that crosses a boundary is CultCache-encoded (one serialisation model,
no JSON), but only state that outlives a tick is a record in a store. Inputs, ticks
and snapshots are typed CultNet message payloads, not store records. The store
receives exactly one write set per tick (ruled `tick-state-commit`: tick-batch).
| Kind | Store | Identity (what names it) | Lifecycle (what happens to it) | Authority (who decides; forbidden writers) |
|---|---|---|---|---|
| **Game definition** (documents, systems, authority declaration, tick rate) | Proposed: one CultCache document per server process (`gamecult.mesh.game_definition`), read-only to everyone but the server's startup. | Game id string from code (`"orbs"`), namespaced by Verse id. The id alone is not injective (two builds share it), so proposed identity is `(game id, definition hash)`, the hash derived from the lowered definition: document schema ids and versions, system ids, order and marks (`[Predicted]`, later `[Adjudicable]`), tick rate, authority declaration. Schema ids (`orbs.player`) are global strings; the game prefix is convention, not enforced (body fact F9). | Created at server startup from code; never revised at runtime; a new build supersedes it with a new hash. Clients carry their own compiled hash (C# in wasm, ruling `browser-sim-runtime`) and are refused at join on mismatch. A declaration a mode cannot honour is refused at startup (`modes-do-not-leak`). Never replayed. | The game's code, through the builder, at startup; the runtime validates. Forbidden: clients, any runtime mutation, Odin (Odin may read and index it, never write it). |
| **Game document** (Player, Pellet, Shot) | CultCache documents in the room store. | `(schema id, record key)`. Today the key is a free string the caller picks; `[CultIndex] Id` in the illustration is an index, not the key. Proposed: the runtime mints keys. Player key derives from the session principal (one Player per principal per room). Spawned documents (Shot, Pellet) key on `(spawner key, tick, spawn ordinal within that tick)`, so a client's predicted spawn and the server's authoritative spawn derive the same key and reconcile instead of duplicating. That is injective as long as spawn order within a system step is deterministic for the predicting client, which the predicted system's own code gives under one runtime (C# both sides). The developer's `Id` field becomes redundant; whether to keep it as a mirror of the key is an API call for the cut, not an operator fork. | Created by a system during a tick, or by the join system (Player). Revised at most once per tick, in that tick's commit. Deleted by a system (pellet eaten) or by the game's leave system after a session ends. Across reconnect within the grace window the Player record is unchanged; only the session's connection binding moves. Across a server restart: gone (ruled `room-durability`: ephemeral; a restart is a new room epoch at tick 0). Replayed only on the client, during reconciliation; the server never replays in stage 1. | The server tick runner on the room's primary is the one writer of committed state, and systems write only through the tick's working set. Forbidden writers: client document puts through `CultNetDatabaseServer.HandlePutAsync` (today accepted from any peer, F1); `PutPredictedAsync` and `CultMeshGameSession.PredictAsync` on any networked store; game code calling database APIs; the simulation-fact committer (F7). |
| **Player input** | Transient. A CultCache-encoded payload on a CultNet message; never a store record in stage 1. | `(session principal, input sequence)`, the sequence monotonic per session; each input also carries the tick it targets. Namespace: the room. Injective given the principal. The input type is a schema'd CultCache type, so the schema catalog publishes it like any document. | Sampled each client tick (devices read by the TS shell, handed to wasm); sent with the last few unacknowledged inputs repeated, so one loss is absorbed; buffered by the server per session against its target tick; consumed exactly once by the matching tick (proposed policy for late inputs: applied to the next tick and counted); acknowledged in the snapshot as "last processed sequence"; kept by the client until acknowledged, for replay; then discarded. Stage 4 changes this: inputs become signed, hash-chained and retained for a dispute window. | The client's principal authors the content; the tick runner decides which tick consumes it. Forbidden: server code altering input content; other clients; any input that arrives without a bound session. |
| **Tick** | Transient counter; proposed: the last committed tick is written into the room record in each tick's batch, so a snapshot or checkpoint knows it. | `(room id, room epoch, tick number)`, the number starting at 0 when the room epoch starts. Injective with the epoch. | Advanced only by the server tick runner at the one declared rate (ruled `tick-model`: one-fixed-rate); never revised. The client estimates the server tick from snapshots and runs its predicted tick ahead by its round trip plus a buffer; on reconnect it re-syncs from the next full snapshot. | Server tick runner. Forbidden: client clocks, transport arrival time, wall-clock reads inside systems (systems receive the tick, not the time). Today no tick scheduler exists anywhere in the C# body (F6). |
| **Predicted state and reconciliation** | Client-side only. In the browser they live in the C# wasm CultCache store; the TS renderer reads a per-frame view (ruled `browser-client-store`: wasm-owns-store). | Same `(schema id, record key)` as the authoritative document, plus the client tick it was predicted for. | Each client tick: predicted systems run over owned documents and the client's own spawns. On a snapshot for tick T acknowledging input sequence S: predicted documents are replaced by the authoritative ones at T, then inputs after S are replayed through the predicted systems up to the current client tick; predicted spawns are matched by derived key, and an unmatched one is removed once the acknowledgement passes its tick. Today `Reconciled` means "the authoritative put replaced the predicted copy" with no replay (F3); the replay loop does not exist. | Predicted state owns nothing outside the client. It is derived from the last authoritative snapshot plus unacknowledged inputs plus the predicted systems. The snapshot owns truth. Forbidden: sending predicted documents to the server; `CultNetClientAuthorityScope` in server mode (F4); the renderer writing. |
| **Session and the player's identity** | Proposed: session membership as a small CultCache document in the room store (`gamecult.mesh.game_session`, low churn, readable by systems and present in snapshots). The connection handle is transient process state. | Principal: the fingerprint of a browser-held P-256 key, proved by signing a join challenge (ruled `player-identity`: browser-keypair). Connection: a server-minted id per transport connection (ephemeral). Logical session: `(principal, room)`, bound across reconnects. Today the client's identity is a self-asserted `runtimeId` string (browser client options; `SourceRuntimeId` on operation envelopes); CultMesh's session identity proves the server to the client and not the reverse (F5). The body still has no authenticated client principal (F5, F13); the session cut builds it. | Connect, then join (definition hash check, principal proof), then the session record is created and the join system spawns the Player. Disconnect suspends the session for a grace window; resume binds a new connection to the same session; expiry ends the session, systems see the leave, and the game's leave system removes the Player. On server restart sessions end (ruled `room-durability`). | The server's join and leave handler on the room primary decides membership; the principal proves itself by signing the join challenge. Forbidden: a client-asserted `runtimeId` as identity; transport peer address; peer catalogs. |
| **Authority declaration and the lease it lowers to** | Declaration: inside the game definition. Lease: proposed as a CultCache document (`gamecult.mesh.authority_lease`); today a plain class in an in-memory dictionary (F2). | Declaration: per document type, `(writer, checker, arbiter)`. Lease: `LeaseId`, carrying Verse, peer, roles, shard ids, issuer, validity window, epoch and signature (`CultMeshAuthorityLease`). Namespace: the Verse. | Declaration fixed per build. At startup `Authority.Server` lowers to: the server holds a `ShardPrimary` lease on the room shard, and clients hold no write scope at all. Renewed before expiry; revocation or expiry stops the tick runner from committing; a new primary takes a new epoch. Proposed issuer in stage 1: the server's own deployment key, which clients pin through the browser trust policy, so an indie deployment needs no Odin. Odin-issued leases stay possible. | The game definition decides the declaration; the issuer issues leases; `CultMeshAuthorityResolver` decides whether a peer holds a role on a shard. Today the database's write gate (`CultNetShardDescriptor.IsPrimary`, `CultNetClientAuthorityScope`, both set from options) never consults the resolver: two authority structures (F2, F4). Proposed: the write gate is derived from lease resolution. First step (cut `remote-write-authority`): a peer's put or delete reaches the database only when `CultNetDatabaseServerOptions.RemoteWriteAuthority` grants it; none configured refuses every remote write (F1, GP1). The lease-backed grant needs an authenticated principal bound to the connection, so it follows the session cut. `CultNetClientAuthorityScope` is no longer an owner; it is derived from the declaration, and in stage 1 it is empty. |
| **Shard / room** (stage 1: one) | `CultNetShardDescriptor` (configuration); the room's state is its CultCache store. | Room id `(Verse, game id, room name)`, for example `orbs/main`, carried as the shard id plus the shard epoch. | Created at server start (epoch 1; on restart a new epoch, ruled `room-durability`); never moved in stage 1; ended at shutdown. Join means "join the one room"; matchmaking is out of scope. Interest management is stage 5 per the target, so in stage 1 every client receives the whole room. | The runtime holding the `ShardPrimary` lease. Forbidden: a second primary; clients. |
| **Snapshot sent to clients** | Transient. A CultNet message whose payload is CultCache-encoded documents; never stored. The server keeps per-client baselines in memory for deltas. | `(room, epoch, tick, recipient session, baseline tick)`. | Derived after a tick commit at the send rate; a delta against the recipient's last acknowledged snapshot, or a full snapshot on join and resume; carries the last processed input sequence for that session. Discarded once sent; a baseline is dropped when a newer one is acknowledged. Today the nearest things are per-record change pushes from `CultNetDatabaseSubscriptionServer` (no tick, no acknowledgement, no input sequence) and shard catch-up snapshots in `CultMeshSnapshots.cs` (F8). | The snapshot builder decides nothing; it is derived from the committed tick. Forbidden: the per-record subscription stream acting as a second game replication path. That stream stays for non-game observers. |
| **Simulation fact / witness observation** (found) | `CultMeshSimulationFact` is a CultCache document; observations are hub memory. | `(shard, epoch, frame, subject, kind)`. | Stage 1: not used. The target replaces it at stage 4. | Today `CultMeshGameSession` serves observations by default (`ServeSimulationObservations = true`) and commits quorum facts through the primary (F7). In server mode there is no checker, so the observation server and the committer must not be wired. Forbidden writer in stage 1. |
| **Shard mutation log entry** (found) | `CultNetDatabase` in-memory `SortedList` per shard, plus an optional durable store. | `(shard id, sequence)`. | Today every primary write appends one entry, and the in-memory list is never trimmed (F10). Per-record writes at tick rate would grow it without bound. In stage 1 the tick's write set either bypasses the per-record log or lands as one bounded entry per tick. Ruled: the tick's write set lands as one batch per tick (`tick-state-commit`), in memory (`room-durability`); the bounded per-tick entry is the tick-runner cut's to specify. | `CultNetDatabase` on the primary. Forbidden: per-system-write appends from the tick runner. |
### Cells filled by rulings (2026-10-27)
- Session principal: `player-identity` (browser-keypair). Not yet in the body (F5, F13).
- Browser documents and the renderer's read: `browser-client-store` (wasm-owns-store).
- Room state across restarts: `room-durability` (ephemeral).
- Tick rate model: `tick-model` (one-fixed-rate).
- How a tick reaches the store: `tick-state-commit` (tick-batch; the tick-runner cut times `CultCacheBatch` at tick rate first).
- Stage 1 transport: `browser-transport` (WebSocket behind a port carrying only inputs and snapshots; a WebTransport server in parallel, `webtransport-server-layer`: managed C# over the MsQuic bridge).
- Still unprobed: `CultCacheBatch` commit cost at tick rate for about 50 players plus pellets (wasm measuring probe and tick-runner cut).
## Body facts
All are source reads at CultLib `d1bf21b3` on 2026-10-27 by `imagination-gp-0b`. No
runtime probe was run in this pass. Each fact names the read that would falsify it.
- **F1. The primary applies any peer's document put.** `CultNetDatabaseServer.HandlePutAsync(message, peer)` (`src/GameCult.Networking/CultNetDatabaseServer.cs:307`) calls `ApplyPutAsync(message)` (`CultNetDatabase.cs:1022`), which checks only that this runtime is primary for the shard and the epoch matches (`EnsurePrimary`, `:2003`). `peer` is used only to send errors. Any connected client can overwrite any game document on the server.
- **F2. Leases are not typed state and do not gate writes.** `CultMeshAuthorityLease` is a plain class (no `[CultDocument]`); `CultMeshAuthorityLeaseCatalog` is a `Dictionary` (`CultMeshAuthority.cs:242`). The only `[CultDocument]` types in `GameCult.Mesh` are body publication, CDN chunk and manifest, content transfer state, discovery state, entity prefab package and simulation fact. `GameCult.Networking` (the database) references no lease type; the one Networking file naming `Lease` is `CultNetSchemaMessages.cs`.
- **F3. Reconciliation is replacement, not replay.** `CultNetDatabase.cs:1247-1252`: a predicted put records a prediction key; the next non-predicting write to that key turns the change into `Reconciled`. No code re-applies inputs after an authoritative state.
- **F4. Client write scope is a runtime-id string match from options.** `CultNetClientAuthorityScope.Matches` compares `OwnerRuntimeId` with the database's own `_runtimeId` (`CultNetDatabase.cs:111-153`, `EnsureClientAuthority` `:1934`). It gates local prediction only and has no relation to leases.
- **F5. No authenticated client principal.** `CultMeshSessionIdentity.cs` verifies transports for `(verse, authorityRuntimeId, protocol, routeGeneration)`, which proves the server to the client. The browser client's `runtimeId` (`packages/cultmesh-browser/src/index.ts:182`) and the operation envelope's `SourceRuntimeId` (`CultNetOperationServer.cs:39`) are self-asserted.
- **F6. No tick scheduler.** A grep of `GameCult.Networking` and `GameCult.Mesh` for tick or timestep names finds none. `Frame` exists only as a witness-asserted number in `CultNetSimulationConsensus` and `CultMeshSimulationFact`.
- **F7. The game session serves the witness path by default.** `CultMeshGameSession` constructs `CultNetSimulationObservationServer` when `ServeSimulationObservations` (default `true`) and always constructs a `CultMeshSimulationFactCommitter` (`CultMeshGameSession.cs:70-104`). `PredictAsync` is `PutPredictedAsync` (`:161`).
- **F8. The browser can watch and invoke, not put.** The `cultmesh-browser` README says it "does not expose direct document writes". The client offers `leaseRawDocument` (`index.ts:349`) and `invoke` of typed operations (`:370`), over one WebSocket. On C#: `CultNetDatabaseSubscriptionServer` and `CultNetOperationServer` take `ICultNetSchemaServer`, so they run over `CultNetWebSocketSchemaServer`; `CultNetDatabaseServer` (puts) takes the concrete UDP `Server` (`CultNetDatabaseServer.cs:36`). Operations are request and response with receipts and idempotency keys, which is a poor fit for an input stream at tick rate.
- **F9. Schema-id namespacing is convention.** `[CultDocument("orbs.player", ...)]` ids are global strings; nothing ties a schema id to a game id.
- **F10. The in-memory mutation log is never trimmed.** `RecordMutationLogEntry` (`CultNetDatabase.cs:1610`) inserts into a per-shard `SortedList`; no `Remove` or trim on that list exists in the file. Compaction applies to the durable store and to burned sequences only.
- **F11. CultCache has a write-set batch; CultNetDatabase does not expose one.** `CultCacheBatch` with `Commit` and `TryCommit` (`GameCult.Caching/CultCache.cs:1796`, `:2212`) is the 2.0.0 write set (`variants:ruling:write-set-content-version-declared-ownership`). `CultNetDatabase` has no batch method: every write is a per-record door.
- **F12. A reflection site on the mesh put path.** `CultMesh.cs:3502` `PutDatabaseDocumentAsync` picks `PutAsync` or `PutPredictedAsync` by `GetMethods` and `MakeGenericMethod`, which matters to the AOT prerequisite (`cultlib-gaps:follow_up:cs-aot-safe-registration`; probe in `research/cultmesh-garden-path/cultcache-aot-probe-2026-10-27.md`).
- **GP1 (runtime probe, 2026-10-28, `imagination-gp-stage1`).** Scratch NUnit test at `51ca81a1`, run on Yggdrasil (`ygg-verify.sh`, `mcr.microsoft.com/dotnet/sdk:10.0`, filter `ImagGpProbeTests`, never pushed to origin). The server writes `gp:player` = "server-owned" through `CultNetDatabase.PutAsync` on a primary shard. `CultNetDatabaseServer._putHandler`, invoked with an uninitialized `CultNetServerPeer` (no field set: no endpoint, no transport, no identity) and a put of "client-overwrote", leaves the record "client-overwrote", and `Server.CanProcessBeforeVerification(put)` returns `true`. Output: `GP1 after=client-overwrote canProcessBeforeVerification=True`, Passed 1. This confirms F1 at runtime: nothing about the peer is consulted, and puts and deletes pass before the legacy login verification (`Server.cs:562-575`).
- **F13. A server peer carries no identity.** `CultNetServerPeer` (`Server.cs:829`) holds the LiteNetLib `NetPeer`, its endpoint and transport stats. The only principal on `Server` is the legacy username and password login (`PlayerData.PlayerId`, `IsVerified` `:697`), which the database door never reads. `ServerSecurityOptions` is one shared `ConnectionKey`: transport admission, not authority.
- **F14. A new refusal code is a coordinated change.** The `cultnet.error.v0` `code` is a closed enum (`contracts/cultnet/cultnet.error.schema.json:28-31`, with a byte-identical copy under `packages/cultnet-rs/contracts/`). Rust `CultNetErrorCode` (`packages/cultnet-rs/src/contracts.rs:242-273`) refuses unknown codes, `packages/cultnet-rs/tests/error_message.rs` decodes captured C# bytes per code, and TypeScript imports the root schema (`packages/cultnet-ts/src/contracts.ts:10`). `unowned_schema` was added this way and is the pattern.
- **F15. Forwarding re-sends as a new client.** `TryForwardPutAsync` (`CultNetDatabaseServer.cs:654`) forwards a `not_primary` put through `ICultNetShardWriteForwarder`, and `CultNetSchemaWriteForwarder` opens its own client connection (`CultNetShardWriteForwarding.cs:122-128`). The primary therefore sees the forwarding runtime, never the original peer.
- **F16. Consumers of the remote write door.** No C# consumer outside CultLib constructs `CultNetDatabaseServer` (grep of the Aetheria, Epiphany, Ghostlight, huginn and gamecult-ops `*.cs` files finds only vendored CultLib copies). In CultLib, tests reach the put and delete handlers directly in `CultNetJournalTests.cs:738`, `:935-936` and `SoulP4cProbeTests.cs:57`. `CultNetDatabaseServer.ApplyPutAsync` and `ApplyDeleteAsync` (`:73`, `:81`) have no caller in `src` or `tests` besides the handlers. TypeScript puts (the `cultnet-ts` Idunn presence and signed health publishers) target Rust servers.
- **F17. The Rust document server admits a put by size only.** In `packages/cultmesh-rs/src/rudp_document_server.rs:682-720`, a `DocumentPutRaw` is refused only when its served response would not fit; no authority is asked. This is F1's gap in the Rust runtime (a follow-up, not stage 1).
## Cut sequence (stage 1)
One line per cut not yet mapped. The model page has no empty cell as of 2026-10-28. Remote write authority (`cultmesh-garden-path:cut_spec:cut-remote-write-authority.r1`, mapped 2026-10-28, so no longer a line here) goes first: it waits on nothing, every later server cut stands on a closed write door, and GP1 shows the door open today. The wasm measuring probe is the sibling that may run beside it, since it touches no file this cut does; ruling `browser-sim-runtime` names it the first cut of the browser runtime track, not of stage 1 as a whole.
- **Wasm runtime measuring probe.** Download size, startup and 60 Hz tick cost of a stub orbs tick under Mono browser-wasm and NetWasm, including `CultCacheBatch` commit cost at tick rate. This is the first cut named by ruling `browser-sim-runtime`, which it can overturn. Its NetWasm and AOT variant waits on `cultlib-gaps:follow_up:cs-aot-safe-registration`.
- **Witness services off in server mode.** `CultMeshGameSession` stops constructing the observation server and fact committer when the declaration has no checker (F7). Waits on nothing; can run beside remote write authority.
- **Leases as typed state and the lease-derived grant.** `CultMeshAuthorityLease` becomes a CultCache document, the lease catalog a store view, and the Mesh host's `RemoteWriteAuthority` resolves the connection's principal through `CultMeshAuthorityResolver`. Waits on remote write authority and on session and principal.
- **Demote `CultNetClientAuthorityScope`.** It becomes derived from the authority declaration (empty in server mode). Waits on game definition.
- **Changelog entry for the remote write refusal.** `unity/org.gamecult.cultlib/CHANGELOG.md` is also written by `variants:cut_spec:cut-write-ownership-cs.r1`, so Self adds this cut's bullet at merge, after that cut lands, and the two specs share no file.
- **Game definition and server tick runner.** Builder, document and system registration, definition document and hash, fixed tick, tick-boundary commit. Rests on rulings `tick-model`, `tick-state-commit` and `room-durability`; waits on nothing mapped.
- **Session and principal.** Join, resume and leave; principal proof; session document. Rests on ruling `player-identity`; waits on game definition (the join checks the definition hash).
- **Input and snapshot path.** Input messages with redundancy, a server input buffer, acknowledgements, delta snapshots. Rests on ruling `browser-transport`; waits on session and principal.
- **Browser client runtime.** Wasm simulation host, TypeScript shell for transport and rendering, prediction with replay reconciliation. Waits on the measuring probe and `browser-client-store`.
- **Orbs reference game and garden-path proof.** The game written with no networking code (`garden-path-needs-no-networking`), and a browser client that writes (`browser-is-first-class`). Waits on all of the above.

View file

@ -1,153 +0,0 @@
# CultMesh garden path: target
Status: draft target for campaign `cultmesh-garden-path`, 2026-10-27. It holds the
ends, not the means. The code below is illustrative: it shows what a developer
should have to write, and no name in it is decided.
## The problem
Operator, 2026-10-27: "the value is in solving the multiplayer scaling problem
for indie developers in an ergonomic fashion. Having one authoritative server is
fine, up to a point, but there's no real open source playbook on how to ship an
MMO, and existing solutions like SpacetimeDB have a huge ergonomics gap. I want
CultMesh to feel like magic: as a garden path, you annotate your domain POCOs and
simulation code and declare your authority structure ... and it'll all just work
without the developer needing to understand networking deeply."
Prior art: `research/cultmesh-garden-path/prior-art-2026-10-27.md` (frameworks,
ergonomics, scale-out) and `research/witness-authoritative-networking/` (consensus
and P2P anti-cheat). What the field shows, in short:
- One API over several authority modes ships today (Photon Fusion: Server, Host,
Shared; Unity NGO: client-server and distributed authority), and in both the
modes leak into the developer's code (`IsServer` against `HasAuthority`,
features missing in one mode).
- SpacetimeDB gives one machine per database. Scaling past it, client
prediction, and the reducer-shaped rewrite of game code are left to the
developer.
- Determinism, where it is required at all, is required of the whole simulation
(Croquet/Multisynq, Photon Quantum), and that tax is the main ergonomic cost.
- Shipped world-scale designs treat a spatial unit as indivisible and move
players between units (EVE, Pax Dei, Folia); nobody publishes an open,
end-to-end playbook (Astron, the nearest, is discontinued).
## What a developer writes (illustrative)
The reference game is a small browser .io game (operator, 2026-10-27: "the .io
game, Aetheria later"): players steer orbs, eat pellets, and shoot.
```csharp
[CultDocument("orbs.player", "orbs.player.v1")]
public sealed class Player
{
[CultIndex] public string Id { get; set; } = "";
public Fix2 Position { get; set; }
public Fix2 Velocity { get; set; }
public int Mass { get; set; }
}
[CultDocument("orbs.shot", "orbs.shot.v1")]
public sealed class Shot
{
[CultIndex] public string Id { get; set; } = "";
public string Shooter { get; set; } = "";
public Fix2 Origin { get; set; }
public Fix2 Direction { get; set; }
}
// Runs every tick. Predicted on the owning client, corrected by authority.
public sealed class Movement : CultSystem
{
[Predicted]
public void Step(Player player, PlayerInput input, Tick tick) { /* ... */ }
}
// A non-owner may re-run this to settle a dispute, so it must be
// deterministic. The analyzer enforces that on this system only.
[Adjudicable]
public sealed class Hits : CultSystem
{
public HitResult Resolve(Shot shot, Rewind<Player> target) { /* ... */ }
}
var game = CultMesh.Game("orbs")
.Documents<Player, Pellet, Shot>()
.Systems<Movement, Eating, Hits>()
.Authority(Authority.Server);
// .Authority(Authority.TrustedCoop);
// .Authority(Authority.Witnessed(w => w.Challenger(Party.Victim).Quorum(3).Arbiter(Party.Server)));
// .Authority(Authority.Regions(r => r.Grid(512).Handoff())); // later: past one server
```
Changing the authority line is the whole migration between modes, unless the new
mode re-executes a system on a non-owner and that system is not yet
deterministic. Then the analyzer names exactly those systems.
## Invariants
- `one-model`: server-authoritative, trusted co-op, witnessed and regioned
authority are configurations of one authority declaration, answering per
document: who writes, who checks, who arbitrates. No mode has its own API for
state, systems or queries.
- `modes-do-not-leak`: game code does not branch on the authority mode or ask
"am I the server"; a system states what it does and the runtime decides where
it runs. Where a mode cannot offer a capability, the declaration is refused at
startup with a reason, never degraded silently at runtime.
- `determinism-is-per-system`: only code a non-owner may re-execute must be
deterministic. That requirement is marked on the system and checked by an
analyzer at build time, not by documentation or by desync in production.
- `state-is-cultcache`: game state is ordinary CultCache documents; the
framework adds no second state model and no JSON on the load-bearing path.
- `authority-is-typed-state`: who holds authority over what is CultMesh state
(leases, shard primaries), inspectable and revocable, never inferred from
transport success or peer catalogs.
- `garden-path-needs-no-networking`: the reference game is written without the
developer touching sockets, serialisation, tick scheduling, prediction or
reconciliation code.
- `honest-guarantees`: each mode states its fault model (trusted server, trusted
peers, honest-majority sampled witnesses with server arbitration) and its
documentation and tests claim nothing past it.
- `browser-is-first-class`: the reference game's client runs in a browser and can
write (submit inputs, own predicted state), not only watch.
- `built-for-load`: what the garden path ships that is database-shaped (the
tick commit, the batch door, logs, subscriptions) is architected for
unreasonable load from the start (operator 2026-10-27,
`cultlib-gaps:ruling:database-shaped-built-for-load`): "if what we're shipping
is database shaped, people will use it as such".
## Sequence (each a campaign or stage of its own; one foundation at a time)
1. Server-authoritative garden path for the .io game: documents, systems,
ticked scheduling, client prediction and reconciliation, browser client that
writes. This is the proof of ergonomics.
2. Trusted co-op: authority moves to the owning peer; same game code.
3. Deterministic dispute kernel (CultMath fixed-point hit tests, cross-runtime
vectors) and the `[Adjudicable]` analyzer.
4. Witnessed authority: signed, hash-chained inputs; victim challenge; sampled
network-near witnesses; server arbitration; browser-to-browser channels.
Demonstration: a cheating client caught and corrected.
5. Regions: spatial authority handoff and interest management past one server.
6. Aetheria on the same declaration.
## Not in scope
- Aetheria until stage 6. Eve and Gjallar (deprecated).
- A new CultCache storage engine (the paged single-file store stays postponed,
`variants:ruling:page-tree-store-postponed`). The garden path fills gaps in
their owners, built for load.
- Asynchronous Byzantine total ordering, zero-knowledge proofs, and chains of
any kind.
- The existing witness aggregator (`CultNetSimulationConsensus`, unsigned and
weighted by observed votes) as a base for stage 4: it is the trusted co-op
first stage by design and will be replaced, not extended.
## Open forks (to the Fork Desk, each with prior art first)
1. Where simulation code runs in the browser: C# compiled to WebAssembly (one
implementation everywhere, which also shrinks the determinism problem to
fixed-point maths, at the cost of runtime download size), C# on the server
with TypeScript systems in the browser (two implementations of each predicted
system), or another arrangement.
2. Server transport for the browser: WebSocket as today, or WebTransport
(QUIC datagrams) on the provider side.
3. Tick model: fixed tick with a declared rate, or per-system rates.

View file

@ -34,7 +34,7 @@ ran under the session scratchpad (`imagination/`), not in the repo.
| F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source |
| F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep |
| F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source |
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggered on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` when this was written; it now triggers on the three `-py-v*` patterns only, because npm publishing is deliberately off until ruled on (a tag run uses the workflow at the tagged commit, so `cultcache-ts-v0.15.0` on `7d292085`, before that change, still ran the old one). Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returned 404 anonymously on 2026-11-08 for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` only. Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
| F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 |
| F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` |
| F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim |
@ -276,10 +276,11 @@ remains:
history (Keep a Changelog headings).
- A Soul pass over the release diff, then four tags: `cultcache-ts-v…`,
`cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`.
These go in **two pushes** (at most three tags per push, SKILL.md). No
`-ts-v*` tag triggers `publish-packages.yml` on main (F16), so these tags
publish nothing;
follow-up `npm-publish-ts-packages` owns npm publishing.
These go in **two pushes** (at most three tags per push, SKILL.md):
`cultcache-ts` first, because its tag triggers `publish-packages.yml`
(F16), and the three plane tags second. Whether the `cultcache-ts` tag's
npm publish job still runs, and to where, is checked before that push;
follow-up `npm-publish-ts-packages` owns the rest.
- The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the
tagged commit) is StreamPixels' B-cuts, not this campaign.
- Size: about 60 lines of version and CHANGELOG text; a short Hands pass.

View file

@ -441,8 +441,8 @@ consumer that waits wants delivered, which is now just Acknowledged.
| `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` |
| `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) |
| `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it |
| `cultmesh-ts/src/index.ts:6211-6303` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:97-136` `sendPresenceDocument` (formerly `publishDocument`), behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts |
| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) |
| `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published |
| `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) |
| C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None |

View file

@ -91,121 +91,6 @@ Probes run 2026-10-12 from `F:\Projects\CultLib-c2afix` with git only (no builds
`unstored-same-tick-unpinned`) caught at head. `git grep RawBytes de401056 -- src tests`: no
match. Those findings were never closed in the mind.
### Body at 2026-10-15: element ids and the MessagePack walk (Modeling, from the Imagination and Soul passes)
Probes ran on Yggdrasil (`mcr.microsoft.com/dotnet/sdk:10.0`, MessagePack 3.1.7 in every csproj) as probe
tests that were never committed: at `6afa8ed9` for I1 to I4, at `494ad9ab` for r4 and r5, at `6e70626b` for r6.
Logs and sources are under `F:\Projects\eureka-handoff-aux-2026-10-15\scratchpad-self-2026-10-14b-eureka\`
(`imag\`, `imag-r4\`, `imag-r5\`; Soul's `soul-c2a-id-holes-s5\` is in that session's scratchpad). The typed
record of each fact is in the mind: `variants:cut_spec:cut-c2a-element-id-holes.r5` and `.r6` (`first`),
verdicts `cut-c2a-element-id-holes.s5` and `.s6`, spec `cut-c2a-ids-header.r1` (`first`). This section keeps
what was run and what it showed.
**What an id is on disk and in memory.**
- An id is an ordinary keyed string in the payload. A list element whose `[CultElementId]` member holds
`a1b2c3d4e5f6` and one whose plain `Tag` holds the same value encode to identical bytes
(`92A1649192AC61316232633364346535663607`; `c2a-ids-header` r1 probe, base `2774a7fe`). No reader can tell
from a record's bytes that it holds ids; only the store header says so.
- `StubArm`, a union arm that is not an object type, is never minted (`imag\imag1.log`, I1): for a document of
`StubArm` and `Unit8`, `Plan` counts 1, `StubArm.Id` stays `''`, `Unit8.Id` is minted, `Inspect` reports 0
problems.
- Resolver over-mark (I2): a consumer `[assembly: CultCacheFormatterResolver]` claims a keyed arm with a
formatter that writes only `N`. The cache mints an id in memory, `Holds` is true and the header is stamped
`v3`, but the payload (`92A263319192009104`) carries no id, and a reopen mints a different one (`8f5e929888ca`
in memory, `a1ea2b683d98` after reload). The header claims ids the disk lacks.
- `[MessagePackFormatter]` on an element type is honoured (I3): the attribute formatter wrote
`92A26131919104`, no id, and `Inspect` reported 0 problems at that base. Registration refuses it from r5 on
(`declares [MessagePackFormatter]`).
- Python store (I4): `py-store/leaf-record.cc` (header `v1`, ids `000000000021/22`) loads in C# with
`HoldsIds = true`, `IdsInMemoryOnly = false`. A C# write that adds an unrelated record copies it forward,
the header stays `v1`, and the ids survive reload. cultcache-py, -ts and -rs have no declaration of which
member is an id, so they keep a `v3` they found and never decide one.
- Header narrowing (`c2a-ids-header` r1): per-record `StoredHoldsIds`, set from the type that decoded a
record, is falsified by the identical-bytes probe above. The durable header owns "these stored bytes hold
ids" at store granularity. A write that copies any record forward keeps a marked header; only a write that
encodes every record decides by content. `StoredHoldsIds` and `KnownIdFree` are deleted.
- Object list versus value list (r4, `imag-r4\probe1.log`, `probe2.log`): ids sit on object-list elements only
(ruling `q7-object-list-elements-only`). `Dictionary`, `ConcurrentDictionary`, `SortedDictionary`,
`IReadOnlyDictionary`, and `ILookup` of keyed arms round-trip with the id at
the element's own slot (e.g. `ConcDict` `81A16B92AC30313233343536373839616200`: key, then `[id, 0]`). A list
or map of scalars has no element to carry one and never enters the id path.
**How MessagePack decides what it writes** (r5 `imag-r5\probe1.log`, `probe3.log`; Soul s5 `job2.log`).
- Member discovery, `MessagePack.Internal.ObjectSerializationInfo.CreateOrNull(Type, bool forceStringKey,
bool contractless, bool allowPrivate)`, is internal, and so is `StandardResolver.Resolvers`. The public
surface allows one test: `StandardResolver`'s formatter for `T` is reference-equal to the formatter one
public singleton hands out for `T`, each caching one instance per type. `DynamicObjectResolver.Instance`
matches keyed classes and structs, `DynamicUnionResolver.Instance` `[Union]` types,
`DynamicGenericResolver.Instance` List, arrays, maps, KeyValuePair, tuples, Lazy, Memory, ArraySegment and
Nullable, and `ImmutableCollectionResolver.Instance` (namespace `MessagePack.ImmutableCollection`)
`ImmutableArray` and `ImmutableList`. Asking `DynamicObjectResolver` for a collection type throws
`TypeInitializationException`, so a family probe swallows exceptions.
- Kind facts (`probe3`): a `[MessagePackObject]` class implementing only `IEnumerable<T>` or
`IReadOnlyList<T>` (`KBag`, `KNode`, `KRo`) is written by key (object resolver). One that is also
`ICollection<T>`, a `List<T>` subclass or a `Dictionary` subclass (`KC2`, `KColl`, `KList2<T>`, `KDict`) is
written as a collection (generic resolver) and its `[Key]` members are dropped.
- Member facts (`probe3`; `ap` is `AllowPrivate`, `R` written, `W` read back). A member is written when it
has an integer `[Key]`, no `[IgnoreMember]`, and is a public field or a property with a public getter. It is
read back when it is a public non-readonly field, a property with a public setter (`init` counts), or a
`[SerializationConstructor]` parameter. At `ap=False`: `PG` (private getter) `R=False W=True`, `PS`
(private setter) `R=True W=False`, `GetOnly` `R=True W=False`, `InitOnly` both. `IdPriv`
(`Id { get; private set; }`) is written and not read back (wire `91AC62...`, back `<null>`), and `IdCtor` is
`W=False`. At `ap=True` every one of these, `priv` included, is `R=True W=True`, except `GetOnly`
(`W=False`).
- AllowPrivate attribute form (Soul s5 `SoulS5Probe.C`, `ApTrueArm`): on a type declared
`[MessagePackObject(AllowPrivate = true)]`, `StandardResolver` writes private `[Key]` members and a
private-setter id round-trips. The oracle test calls `CreateOrNull(type, false, false, false)` and reports
none of them, so the walk's member set misses that form.
- Collection written as object (r6 `probe2.log`): a `[DataContract]` class implementing only `IEnumerable<T>`
over an id-carrying arm (`DcSeq`) is a collection in the walk, yet `StandardResolver`'s formatter is
`DynamicObjectResolver`'s (wire `91 91 91 92 AC..`, written as an object). A `[DataContract]` `List<T>`
subclass (`DcColl`) is written by the generic resolver, and the object resolver hands out a different
instance for it. For `DcBad` (two members on one `Order`) `StandardResolver` uses the generic resolver, and
Soul s5 measured both `DcColl` and `DcBad` passing the door.
- Geometry (r6 `probe2.log`, `probe3.log`): Geometry has a direct MessagePack 3.1.7 `PackageReference`, so the
source generator runs, and its assembly carries `[GeneratedAssemblyMessagePackResolverAttribute(
typeof(GeneratedMessagePackResolver), 3, 1)]`. For `CultGeometryDomainNode`, `DomainDocument` and
`FeatureClaim`, `StandardResolver` returns `GeneratedMessagePackResolver`'s formatter and every public
singleton returns a different instance or null. For `CultGeometryDomainNode[]` and `FeatureClaim[]` it is
the generated resolver's `ArrayFormatter`, not `DynamicGenericResolver`'s. No Geometry id-path type matches
a family singleton. Caching.Tests has no generator (analyzers do not flow through a `ProjectReference`), so
its keyed types match `DynamicObjectResolver`.
- Generated assemblies (Soul s5, fresh generator-enabled assembly `SoulGen`, `job2.log` GEN rows): a keyed
`List` or `Dictionary` subclass (`GKColl`, `GKDict`) is written by key, wire `["c",[7]]`, with its elements
dropped by MessagePack; the walk reads it by key too (Plan 0). A generated-assembly `[DataContract]`
enumerable (`GDcSeq`) is still written by `DynamicObjectResolver`, because the generator emits no
DataContract formatters.
- Null standard (Soul s6 finding `null-standard-untyped`): for a non-generic interface or abstract class that
enumerates arms, `StandardResolver` returns null instead of throwing, and the door neither refuses nor
types it.
- Wire pin (Soul s5, s6 `SoulS5Pin`): a deterministic document with 12 minted ids, encoded by the codec and
decoded independently with `MessagePackSerializer.ConvertToJson`, carries every id. The ids are
byte-identical at `494ad9ab` and `57fdf70c`, `0f8964b58a08` and `7eb4fb13d530` among them.
- Not measured: Unity/IL2CPP, where the package bundles its own `MessagePack.dll` (version not read) and the
dynamic resolvers cannot emit, and MessagePack versions other than 3.1.7.
**Whole-list holders at `57fdf70c`** (Imagination r2 for `c2a-id-closed-world`, 2026-10-16; probe tests in a
scratch commit `dc629459` that was never pushed, Yggdrasil `sdk:10.0`; logs `job1.log` and `job2.log` in the
Imagination scratchpad `imagination-c2a-closed-world-r2`).
- Shapes the walk does not own already behave as whole values. A keyed document holding
`ImmutableArray<PElem>`, `(List<PElem>, int)`, `KeyValuePair<string, List<PElem>>`, a non-enumerable
`[DataContract]` holder, or a string-keyed holder (`[Key("items")]`) gives `Inspect` problems 0, an empty
IdPath, `CanHoldIds` false, `Plan` 0 (random and deterministic), and `Holds` false with ids set.
- The marker and the copy check do not count them. Committed with ids `aaaaaaaaaaaa`/`bbbbbbbbbbbb`, the store
header stays `cultcache.store.v1`. A fresh cache that writes another record over that file (the copy-forward
check) keeps `v1`. Reload returns the ids exactly as written, and unset ids stay null after write and reload.
- Variants replace them whole, and that is the C2a resolver's behaviour for every list. A variant `Set` of the
`ImmutableArray` slot resolved to `[9:eeeeeeeeeeee]` with the base untouched, header `v2`, `HoldsIds` false.
A path step `[1:aaaaaaaaaaaa]` is refused: "this runtime resolves top-level Set overrides only". No C2a gap.
The gap belongs to C2b, in the cut sequence below.
- AllowPrivate is read from the most derived `[MessagePackObject]`. `ApDerived` (AllowPrivate over a base
without it) is written `[[],[],4,[]]`: base-chain privates and a private-getter property, with
`ObjectSerializationInfo` at `allowPrivate: true` = `[baseStash, own, N, Pg]`. `NoApDerived` (plain over an
AllowPrivate base) is written `[nil, 5]`. The walk read only `[N]` for both.
- A `[DataContract]` `List<T>` subclass is written by `NonGenericListFormatter`, and its record does not read
back ("System.Object[] is not of type PElem"), whatever the walk does. That is a MessagePack limit, not an
id fact.
## 2. Model page (step 0b)
| Kind | Named by | Over time | Decided by |
@ -214,16 +99,16 @@ Imagination scratchpad `imagination-c2a-closed-world-r2`).
| Base reference | the base's key, in the record's variant slot | changed only by an explicit rebase; a base cannot be deleted while it has variants, unless the same batch rebases or removes them | author; the cache refuses a cycle, a missing base, a different concrete type, a global type |
| Override entry | `(op, path, id, value)`; op is Set, Insert or Remove | added by editing; removed by clearing; survives base edits while its path resolves | author; the cache refuses entries a base edit strands (Q2); an entry whose member left the type soft-drifts with a warning (Q4 c) |
| Member path | a sequence of `(slot, elementId)` steps; `slot` is the MessagePack `[Key]`; `elementId` is `""` unless the step enters an object-list element | a type change can invalidate it | the type's owner (slot authority); ids by the element's creator |
| Element id | a `[CultElementId]` string member on the element type of an object list in a shape the walk owns (arrays, `IEnumerable<T>`, `IDictionary`/`IReadOnlyDictionary` values, `[MessagePackObject]`/`[Union]` types with integer keys), one MessagePack writes and reads back (integer `[Key]`, no `[IgnoreMember]`; public, or any visibility under the most derived `AllowPrivate`); `""` means unset. **Exception (q3 amended 2026-10-16):** a shape the walk does not own is one whole value: `ImmutableArray`, `ArraySegment`, `Memory`, `KeyValuePair`, tuples, `Lazy`, unkeyed or `[DataContract]` classes, string-keyed types, a type or member with `[MessagePackFormatter]`, and a non-public member without `AllowPrivate`. Its elements carry no ids: a `[CultElementId]` inside is a plain string | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element. Inside a whole value: never minted, never checked, never counted for `v3` | cache mints it; it is never retargeted. `CultElementIds.Classify` alone decides owned versus whole |
| Element id | a `[CultElementId]` string member on an object-list element type; `""` means unset | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element | cache mints it; it is never retargeted |
| Resolved view | `CultStoredDocument.Document` for a variant key | recomputed inside the hold that admits the variant, or any base on its chain | the cache only; never persisted, never written back (Q1) |
| Index and name entries | as today, from the resolved view | a dependent's entries are dropped and re-added in the same `Apply` as its base change | the cache; a variant must override the `[CultName]` member (rule R6) |
| Watches | as today; each re-resolved dependent emits `Updated` with its own `Sequence` | published in the same hold as the base change | the cache |
| Compare-exchange | `(schemaId, storedAt)` of the stored delta (ruling 2) | a base change does not stamp its variants | the cache; the resolved view is not an identity |
| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5); a marked `v3` (`v5` directory) stays while a write copies any record forward, and only a write that encodes every record drops it by content | the writing store, from content; the durable header alone says the bytes hold element ids |
| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5) | the writing store, from content |
| Single-file store file | the path; one MessagePack array `[header, catalog, records]` | replaced whole and atomically; refused when unreadable; see "Store authorities" | each runtime's one reader decides what is a store; the writer derives the header from it |
| Store lock and staging files | `<file>.lock`; a unique temp beside the store | the lock is taken by C# and Rust writers (CultMesh included, through `ReplaceDurable`); staging is removed on a failed write | the store, never CultMesh or a consumer |
| Variant slot on the wire (CultNet) | **empty cell**: no CultNet message carries a delta today | C5 | ruling 4 decides deltas; the message shape is C5's design |
| Dictionary entries, scalar lists, arrays, whole-list holders | **empty cell**: no stable identity exists | whole-member Set only (Q7; whole-list holders by q3 amended) | Q7, q3 amended |
| Dictionary entries, scalar lists, arrays | **empty cell**: no stable identity exists | whole-member Set only (Q7) | Q7 |
| Template (non-item) bases | **empty cell**: parked by ruling 6 | none | not in these cuts |
## 3. Where the Body contradicts the target
@ -335,21 +220,20 @@ Dictionary entries, scalar lists and arrays are replaced whole.
- **Recommend yes.** Aetheria's cases (`Hardpoints` whole; `Behaviors[id]...Max`) fit. Keyed
dictionary addressing can be added later without a format change. Affects C2.
## Cut sequence (2026-10-15)
## Cut sequence (2026-10-12)
Landed since 2026-10-12: `c2a-declared-apply` (r3, Soul s5) and `main-into-c2a` (r2, Soul s2); the
reconcile step is done, `hands/variants-c2a` is at the reconciled head `6afa8ed9`. Element ids part 1,
`c2a-element-id-holes` (r6), is held by Soul s5/s6 on `hands/variants-c2a-element-id-holes`. Mapped
now: part 2, `c2a-id-closed-world` r2 (2026-10-16, under the amended q3), not blocked. Its open fork
`variants:question:c2a-whole-list-opt-in` holds under every option. Later cuts, one line each, mapped
only when Self is about to dispatch them:
Mapped now (specs in the mind): `c2a-declared-apply` r2, the C# commit-time fix batch on
`hands/variants-c2a` (the stash, finished, with tests and contract text), and its sibling
`main-into-c2a` r2, main merged into C2a on `hands/variants-c2a-main` in its own worktree. Later
cuts, one line each, mapped only when Self is about to dispatch them:
1. **reconcile**: done at `6afa8ed9`.
1. **reconcile**: merge `hands/variants-c2a` into `hands/variants-c2a-main` after both Soul passes
hold; Self moves `hands/variants-c2a` there. Waits on both mapped cuts.
2. **parity-ts**: TypeScript write set, one record per key, undeclared records carried and refused,
unheld removals judged at commit. Waits on element ids parts 1 and 2 merged into C2a.
unheld removals judged at commit. Waits on reconcile.
3. **parity-rs**: Rust `push_all` and `put_prepared_batch` apply a write set, ownership by
declaration, the same commit-time checks. Waits as parity-ts; can run beside it.
4. **parity-py**: Python write-set apply. Waits as parity-ts, and on `cultlib-gaps` cut
declaration, the same commit-time checks. Waits on reconcile; can run beside parity-ts.
4. **parity-py**: Python write-set apply. Waits on reconcile and on `cultlib-gaps` cut
`py-msgpack-default` landing on main (then a main merge into C2a first).
5. **write-set-vectors**: one cross-runtime manifest, read and acted on by all four runtimes;
carries follow-ups `c0-bad-canonical-in-manifest`, `legacy-vectors-all-runtimes`,
@ -358,11 +242,6 @@ only when Self is about to dispatch them:
with an empty diff against the verified head; no tag. Waits on write-set-vectors, Soul holding,
and `asura:cut_spec:cut-semver-measured` landed. Asura Cut 3 bases on its merge commit.
Element-id part 2: `c2a-id-closed-world` r2 decides deferred findings `s5.member-set-misses-allowprivate`, `s5.enumerating-rule-unpinned`, `s6.null-standard-untyped`, `s6.two-recognisers-one-rule`, `s4.unrecognised-holders-fail-open` and follow-up `c2a-datacontract-holder-unwalked` (see its `first`).
- **marker-for-whole-lists** (only if `c2a-whole-list-opt-in` is ruled `marker-when-ids-declared` or `marker-for-any-keyed-object`): a CarriesElements predicate, a refusal unless the member is marked, and a public attribute. It is additive after part 2.
- **C2b constraint** (owed to `variants:follow_up:remaining-variant-cuts`): nested path resolution must refuse a step `[slot:elementId]` that enters a whole value, naming the holder. A whole value is any shape `CultElementIds.Classify` calls Leaf, so C2b asks Classify and keeps no recogniser of its own. Today's C2a resolver refuses every non-top-level step, so no store can hold such an override yet.
Rationale. The 2026-10-02 order (parity, vectors, then merge main) was right when main was close.
Main has since moved 453 commits and rebuilt exactly what the parity cuts change: cultcache-ts
payload ownership, cultmesh-ts, cultcache-rs. Parity mapped before the merge would be mapped
@ -701,7 +580,6 @@ checked by the operator in the editor.
- Run `MintElementIds` once over `GameData/Aetheria.cc`.
- AetherDb migrations skip variants or use the variant API (Q1a).
- Re-author the families listed in section 6.
- Hull families live in each ship's own `.cc`, not in `GameData/Aetheria.cc`, because hulls are mod-owned packages (Nibu's ruling about her data, 2026-11-11, #minds).
### C5. CultNet deltas (C# reference)
@ -1606,10 +1484,10 @@ The one door per runtime, and who walks through it:
- **Inputs.** The bytes at the path. **Outputs.** The decoded store with header and catalog; or a typed refusal (`CultStoreUnreadableException` in C#; `CultCacheStoreUnreadable{path, kind}` in Rust, kind `Undecodable` or `UnsupportedFormat`; `StoreUnreadableError` in TS and Python). A refusal names a header only as `cultcache.store.v<digits>`.
- **Derived state.** The write header is never decided by the writer: it is the reader's verdict (header read, whole-store flag, whether ids or variants are held). A rewrite of a file that cannot be read cannot happen, because the reader throws first.
- **Forbidden writers.** A second header classifier. Deleted: Rust `leading_header`, `read_array_header`, `read_string`; cultnet-ts's own decode of the store header; the TS async `readStore`; Rust `StoreUnreadableError` (negative grep `StoreUnreadableError` in `packages/cultcache-rs`: no match); Python `_decode_v1_snapshot`.
- **Shared paths.** cultnet-ts reaches the header decision only through `readSingleFileStore` (AUTH:4, :321) and checks only record count, schema id and type. cultnet-ts therefore needs a cultcache-ts release that exports it, which is the release that ships with this change; the manifests move to that version then (c0-ships-with-c2a), not before.
- **Shared paths.** cultnet-ts reaches the header decision only through `readSingleFileStore` (AUTH:4, :321) and checks only record count, schema id and type. cultnet-ts therefore declares cultcache-ts `^0.15.0`, the first release that exports it.
- **Cut line.** `merge-r1` h3 (Rust) and h4 (TS, cultnet-ts); spec `cut-merge-r1.r3` authority map.
- **Pinned by.** `tests/vectors/document-variants-c2a/readability/manifest.txt`, walked by `StoreReadabilityTests.OpenReadsExactlyTheFilesTheVectorsSayItReads` (C#), `CultMeshSingleFileReadabilityTests.AWriteReplacesAFileExactlyWhenItReads` (Mesh), `a_file_is_replaced_exactly_when_it_reads` (RS:5132), "SingleFileMessagePackBackingStore replaces a file exactly when it reads" (TS test :2321) and `test_single_file_replaces_a_file_exactly_when_it_reads` (Python). AUTH cases: `packages/cultnet-ts/test/idunn-runtime-authority.test.ts`. Declared divergences in the manifest: `legacy-envelopes.bin` (C# refuses; the others read and replace it) and `variant-slot-v3.bin` (C# reads; the others refuse).
- **Known gaps.** `variants:follow_up:range-check-in-ci` (`npm run test:pack:ts` builds every admitted tag and checks exports, but `.github/workflows/publish-packages.yml` does not run it).
- **Known gaps.** `variants:follow_up:c0-bad-canonical-in-manifest` (`current-catalog-bad-canonical.msgpack` is not in the manifest; TS reads it, Rust refuses). `variants:follow_up:legacy-vectors-all-runtimes` (the three `legacy-catalog-*` c0 vectors have a verdict only in C# tests; all four runtimes refuse them, probed). `variants:follow_up:range-check-in-ci` (`npm run test:pack:ts` builds every admitted tag and checks exports, but `.github/workflows/publish-packages.yml` does not run it).
### B. Existence and links (what "nothing at the path" means)
@ -1620,7 +1498,7 @@ The one door per runtime, and who walks through it:
- **Shared paths.** Open, flush, commit, `ReadDurable`, `ReplaceDurable`. Rust `pull_all` RS:1243 also checks `is_absent` on the store and on the lock path before it takes the shared lock; that skips lock creation only, and the verdict stays with the reader.
- **Cut line.** `merge-r1` h2 (C#) and h4/h6 (TS `readStoreBytes`); `mesh-single-file-reader` h1.
- **Pinned by.** C#: `StoreReadabilityTests` `ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore` :208, `ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore` :185, and the commit variants :196, :219. Mesh: `ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile` :107 and `OnlyNothingAtThePathIsAMissingFile` :134 (`CultMeshSingleFileStoreTests.cs`). Rust: `a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written` RS:5832. TS: "readStoreBytes reports nothing at the path only when the lstat finds nothing too".
- **Known gaps.** The writer no longer tolerates a dangling link: the reader's refusal stands for every write, whole-store `pushAll` included (Rust `a_push_all_through_a_dangling_link_is_refused_like_push_and_writes_nothing`, TS `a pushAll through a dangling link is refused like push`; `variants:follow_up:writer-dangling-link-tolerance`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head.
- **Known gaps.** The writer side still tolerates a dangling link: Rust `write_all_unlocked` RS:992-996 and TS `pushAll` TS:94-100 replace it on a whole-store write (`variants:follow_up:writer-dangling-link-tolerance`, owner `c2a-write-set`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head.
### C. Zero-byte file
@ -1640,7 +1518,7 @@ The one door per runtime, and who walks through it:
- **Shared paths.** Open, commit, Mesh read and write.
- **Cut line.** `mesh-single-file-reader` h1 (the C# clause). Rust, TS and Python were correct by construction of their reads.
- **Pinned by.** C#: `ADirectoryAtTheStorePathIsAnIoErrorOnOpenAndOnCommitNotAnEmptyStore` (`StoreReadabilityTests.cs:236`). Mesh: `ADirectoryAtThePathIsAnIoErrorOnReadAndWriteAndIsLeftAlone` (:149). Rust: `a_write_over_a_directory_stops_at_the_read_and_leaves_no_staging_file` RS:5726.
- **Known gaps.** None open. The `@directory` manifest row is walked by all four runtimes: every operation including delete is refused and leaves the directory untouched; Rust pins an I/O failure rather than a typed unreadable store.
- **Known gaps.** TS and Python have no test for a directory at the store path. No follow-up is registered; the natural home is `write-set-vectors` with the other readability pins.
### E. Lock
@ -1672,7 +1550,7 @@ The one door per runtime, and who walks through it:
- **Shared paths.** TS `pushAll` and Rust `push_all` only.
- **Cut line.** `merge-r1` h4 (TS) and h6 (Rust).
- **Pinned by.** The manifest walks run a soft flush over every vector in TS (the `softPushAll` operation, TS test :2336) and Rust (`soft_push_all`, RS:5149), each with an absent and a dangling-link case; `a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link` RS:6019.
- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` is settled: the non-soft branch refuses a dangling link too.
- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` applies to the non-soft branch.
### What no cut owns yet

View file

@ -1,274 +0,0 @@
# Element-id sweep list (C2a)
Data for the per-consumer sweep in docs/document-variants-cut.md, C2. Every object type below sits in a list (`List<T>`, `T[]`, `IEnumerable<T>`, and the like) reachable from a registered document type, and needs one string member with `[Key(n)]` and `[CultElementId]` before that consumer can take a CultLib that requires ids. A union base takes the member on the base; its subclasses inherit it, so put the slot above the subclasses' highest key.
Method: a static scan of `F:\Projects\<consumer>` C# files for types marked `[CultDocument(`, walked through keyed members, base classes, dictionary values and `[Union]` subtypes. Read-only; no consumer was edited. Worktree copies (`Aetheria-catalog`, `CultLib-*`, and so on) are excluded. Struct elements are values and need no id. Non-C# consumers carry the id as an ordinary member and are not listed. The scan is text-based: a document registered without a literal `[CultDocument(` attribute, or a type whose members lack an integer `[Key]`, is missed, so the sweep pass should confirm each repo by registering its types once.
A type that already has a readable string id keeps it and adds a trailing keyed string member marked `[CultElementId(nameof(ExistingId))]`; see the Element ids section of `src/GameCult.Caching/README.md`.
Lines: `Type file:line (via Owner.Member)`, paths relative to the consumer root.
## Aetheria (13 element types)
- BehaviorData Assets/Scripts/ServerShared/Behaviors/Behaviors.cs:190 (via ConsumableItemData.Behaviors)
- EntityPack Assets/Scripts/ServerShared/EntitySerializer.cs:171 (via ZonePack.Entities)
- ProductRole Assets/Scripts/ServerShared/FactionProduct.cs:38 (via FactionProductData.Roles)
- InputLayoutRow Assets/Scripts/ServerShared/InputLayout.cs:32 (via InputLayout.Rows)
- InputLayoutColumn Assets/Scripts/ServerShared/InputLayout.cs:52 (via InputLayoutKeyRow.Columns)
- ItemRole Assets/Scripts/ServerShared/ItemData.cs:325 (via CraftedItemData.Roles)
- AudioStat Assets/Scripts/ServerShared/ItemData.cs:441 (via EquippableItemData.AudioStats)
- HardpointData Assets/Scripts/ServerShared/ItemData.cs:553 (via HullData.Hardpoints)
- StatTerm Assets/Scripts/ServerShared/ItemData.cs:630 (via PerformanceStat.Terms)
- LoadoutSlot Assets/Scripts/ServerShared/Loadout.cs:23 (via Loadout.Slots)
- RoleFill Assets/Scripts/ServerShared/Provenance.cs:84 (via Lot.Roles)
- SavedActionBarBinding Assets/Scripts/ServerShared/SavedGame.cs:168 (via SavedGame.ActionBarBindings)
- Asteroid Assets/Scripts/ServerShared/ZoneData.cs:133 (via AsteroidBeltData.Asteroids)
## AetheriaEve (115 element types)
- AetheriaCorporationAllegiance Aetheria.State/Documents/AetheriaCorporation.cs:67 (via AetheriaCorporation.Allegiances)
- AetheriaShapeCell Aetheria.State/Documents/AetheriaItemDefinition.cs:178 (via AetheriaItemDefinition.ShapeCells)
- AetheriaItemHardpoint Aetheria.State/Documents/AetheriaItemDefinition.cs:188 (via AetheriaItemDefinition.Hardpoints)
- AetheriaCurveKey Aetheria.State/Documents/AetheriaItemDefinition.cs:222 (via AetheriaItemDefinition.ThermalPerformanceCurveKeys)
- AetheriaItemAudioStat Aetheria.State/Documents/AetheriaItemDefinition.cs:238 (via AetheriaItemDefinition.AudioStats)
- AetheriaBehaviorPayload Aetheria.State/Documents/AetheriaItemDefinition.cs:267 (via AetheriaItemDefinition.BehaviorPayloads)
- AetheriaBehaviorField Aetheria.State/Documents/AetheriaItemDefinition.cs:286 (via AetheriaBehaviorPayload.Fields)
- AetheriaBehaviorMapEntry Aetheria.State/Documents/AetheriaItemDefinition.cs:296 (via AetheriaBehaviorValue.MapEntries)
- AetheriaBehaviorValue Aetheria.State/Documents/AetheriaItemDefinition.cs:306 (via AetheriaBehaviorValue.Children)
- AetheriaLegacyCatalogFile Aetheria.State/Documents/AetheriaLegacyCatalogQuarantine.cs:41 (via AetheriaLegacyCatalogQuarantine.NameFiles)
- AetheriaMigrationCount Aetheria.State/Documents/AetheriaMigrationLedger.cs:35 (via AetheriaMigrationLedger.Counts)
- AetheriaLegacyCatalogEntrySummary Aetheria.State/Documents/AetheriaMigrationLedger.cs:45 (via AetheriaMigrationLedger.LegacyCatalogEntries)
- AetheriaStoryFileHash Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:42 (via AetheriaPlayerSettings.StoryFileHashes)
- AetheriaInputBindingOverride Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:85 (via AetheriaPlayerInputSettings.BindingOverrides)
- AetheriaEntityLoadout Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:119 (via AetheriaEntityLoadout.Children)
- AetheriaLoadoutItemSlot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:186 (via AetheriaEntityLoadout.Equipment)
- AetheriaCargoBayLoadout Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:199 (via AetheriaEntityLoadout.CargoContents)
- AetheriaFactionZoneState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:268 (via AetheriaRunState.HomeZones)
- AetheriaAgentTaskState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:275 (via AetheriaRunState.AgentTasks)
- AetheriaDroppedPickupSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:346 (via AetheriaZoneState.DroppedPickups)
- AetheriaOrbitSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:362 (via AetheriaZoneState.Orbits)
- AetheriaBodySnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:384 (via AetheriaZoneState.Bodies)
- AetheriaBodyResource Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:449 (via AetheriaBodySnapshot.Resources)
- AetheriaAsteroidSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:459 (via AetheriaBodySnapshot.Asteroids)
- AetheriaAsteroidMiningAccumulatorSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:484 (via AetheriaAsteroidSnapshot.MiningAccumulators)
- AetheriaColor Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:545 (via AetheriaGasGiantVisualState.Colors)
- AetheriaFogFieldEmitterSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:691 (via AetheriaEntitySnapshot.FogFieldEmitters)
- AetheriaLoadoutGenerationSelection Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:723 (via AetheriaLoadoutGenerationReceipt.Selections)
- AetheriaEntityContactSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:734 (via AetheriaEntitySnapshot.Contacts)
- AetheriaEntityItemSlot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:751 (via AetheriaEntitySnapshot.Equipment)
- AetheriaWeaponGroupSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:782 (via AetheriaEntitySnapshot.WeaponGroups)
- AetheriaEntityStatGrid Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:789 (via AetheriaEntitySnapshot.StatGrids)
- AetheriaActiveConsumableSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:805 (via AetheriaEntitySnapshot.ActiveConsumables)
- AetheriaConsumableBehaviorStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:828 (via AetheriaActiveConsumableSnapshot.BehaviorStates)
- AetheriaBehaviorProgressSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:844 (via AetheriaEntitySnapshot.BehaviorProgress)
- AetheriaWeaponStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:863 (via AetheriaEntitySnapshot.WeaponStates)
- AetheriaBehaviorStateSnapshot Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:924 (via AetheriaEntitySnapshot.BehaviorStates)
- AetheriaFactionRelationshipState Aetheria.State/Documents/AetheriaRuntimeStateDocuments.cs:1093 (via AetheriaRunState.FactionRelationships)
- AetheriaItemRarityTier Aetheria.State/Documents/AetheriaTradeValuePolicy.cs:29 (via AetheriaTradeValuePolicy.Tiers)
- AetheriaRuntimeAgentTaskCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs:35 (via AetheriaRuntimeRunCheckpointCommit.AgentTasks)
- AetheriaRuntimeAssetManifestEntry Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAssetDocuments.cs:102 (via AetheriaRuntimeAssetManifestDocument.Assets)
- AetheriaRuntimeCurveKey Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:446 (via AetheriaRuntimeShotReceiptCommit.GuidanceCurve)
- AetheriaRuntimeCorporation Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:620 (via AetheriaRuntimeCatalogSnapshot.Corporations)
- AetheriaRuntimeNameFile Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:669 (via AetheriaRuntimeCatalogSnapshot.NameFiles)
- AetheriaRuntimeLoadoutTemplateSnapshot Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeCatalogSnapshot.cs:796 (via AetheriaRuntimeLoadoutTemplatesDocument.Templates)
- AetheriaRuntimeDaemonCommandBoundaryEntry Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs:462 (via AetheriaRuntimeDaemonCommandBoundaryDocument.Commands)
- AetheriaRuntimeDaemonSoaIdentityDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:143 (via AetheriaRuntimeDaemonSoaViewDocument.Identities)
- AetheriaRuntimeDaemonRenderGroupDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:156 (via AetheriaRuntimeDaemonSoaViewDocument.RenderGroups)
- AetheriaRuntimeDaemonSoaBufferDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:222 (via AetheriaRuntimeDaemonSoaViewDocument.Buffers)
- AetheriaRuntimeDaemonSoaColumnDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:253 (via AetheriaRuntimeDaemonSoaViewDocument.Columns)
- AetheriaRuntimeDaemonSoaDirtyRangeDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs:284 (via AetheriaRuntimeDaemonSoaViewDocument.DirtyRanges)
- AetheriaRuntimeEveSurfaceAdvertisement Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs:11 (via AetheriaRuntimeDaemonProviderAdvertisementDocument.EveSurfaces)
- AetheriaRuntimeEvePluginRequirement Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs:58 (via AetheriaRuntimeEveSurfaceAdvertisement.RequiresPlugins)
- AetheriaRuntimeZoneTargetRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:410 (via AetheriaRuntimeZoneContactsDocument.Targets)
- AetheriaRuntimeZoneContactRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:441 (via AetheriaRuntimeZoneContactsDocument.Contacts)
- AetheriaRuntimeStationStockItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:551 (via AetheriaRuntimeStationRefitEntityOption.CargoItems)
- AetheriaRuntimeStationRefitEntityOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:585 (via AetheriaRuntimeStationRefitDocument.AvailableEntities)
- AetheriaRuntimeStationDockingBayRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:620 (via AetheriaRuntimeStationRefitDocument.DockingBays)
- AetheriaRuntimeStationLoadoutRestoreOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:655 (via AetheriaRuntimeStationRefitDocument.LoadoutRestoreOptions)
- AetheriaRuntimeStationCargoTargetRow Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:674 (via AetheriaRuntimeStationRefitDocument.CargoTargets)
- AetheriaRuntimeSectorMapZone Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:765 (via AetheriaRuntimeSectorMapDocument.Zones)
- AetheriaRuntimeSectorMapLink Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:802 (via AetheriaRuntimeSectorMapDocument.Links)
- AetheriaRuntimeZoneRenderAdjacentZone Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1197 (via AetheriaRuntimeZoneRenderDocument.AdjacentZones)
- AetheriaRuntimeZoneRenderWormholeExit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1210 (via AetheriaRuntimeZoneRenderDocument.WormholeExits)
- AetheriaRuntimeZoneRenderBodyPose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1229 (via AetheriaRuntimeZoneRenderDocument.BodyPoses)
- AetheriaRuntimeZoneRenderAsteroidBeltPose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1260 (via AetheriaRuntimeZoneRenderDocument.AsteroidBeltPoses)
- AetheriaRuntimeZoneRenderAsteroidInstancePose Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1286 (via AetheriaRuntimeZoneRenderAsteroidBeltPose.InstancePoses)
- AetheriaRuntimeViewportObject Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1382 (via AetheriaRuntimeGameViewportDocument.Objects)
- AetheriaRuntimeInventoryItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1458 (via AetheriaRuntimeViewportObject.Inventory)
- AetheriaRuntimeGravityInfluence Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1493 (via AetheriaRuntimeGameViewportDocument.GravityInfluences)
- AetheriaRuntimeBodyView Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameViewportDocuments.cs:1530 (via AetheriaRuntimeGameViewportDocument.Bodies)
- AetheriaHangarShip Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:119 (via AetheriaHangarState.Ships)
- AetheriaHangarItemStack Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:129 (via AetheriaHangarState.Inventory)
- AetheriaHangarCurrency Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:136 (via AetheriaHangarState.Currencies)
- AetheriaDeploymentReceipt Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs:155 (via AetheriaHangarState.Deployments)
- AetheriaProgressionVerseOption Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeProgressionSourceDocuments.cs:45 (via AetheriaProgressionSourceDocument.AvailableVerses)
- AetheriaRuntimeStoryFileHashDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSettingsDocuments.cs:87 (via AetheriaRuntimePlayerSettingsDocument.StoryFileHashes)
- AetheriaRuntimeInputBindingOverrideDocument Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSettingsDocuments.cs:108 (via AetheriaRuntimePlayerSettingsDocument.BindingOverrides)
- AetheriaRuntimeEntityLoadoutCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:78 (via AetheriaRuntimeEntityLoadoutCommit.Children)
- AetheriaRuntimeLoadoutItemSlotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:143 (via AetheriaRuntimeEntitySnapshotCommit.Equipment)
- AetheriaRuntimeCargoBayLoadoutCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:159 (via AetheriaRuntimeEntitySnapshotCommit.CargoContents)
- AetheriaRuntimeGameEventCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:490 (via AetheriaRuntimeRunCheckpointCommit.GameEvents)
- AetheriaRuntimeFactionZoneCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:509 (via AetheriaRuntimeRunCheckpointCommit.HomeZones)
- AetheriaRuntimeShotReceiptCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:539 (via AetheriaRuntimeRunCheckpointCommit.ShotReceipts)
- AetheriaRuntimePickupContactReceiptCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:591 (via AetheriaRuntimeRunCheckpointCommit.PickupContactReceipts)
- AetheriaRuntimeDamageCellCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:603 (via AetheriaRuntimeShotReceiptCommit.DamageCells)
- AetheriaRuntimeCorporationSurveyCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:614 (via AetheriaRuntimeRunCheckpointCommit.CorporationSurveys)
- AetheriaRuntimeFactionRelationshipCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:623 (via AetheriaRuntimeRunCheckpointCommit.FactionRelationships)
- AetheriaRuntimeZoneSnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:636 (via AetheriaRuntimeRunCheckpointCommit.Zones)
- AetheriaRuntimePhysicalPayloadCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:699 (via AetheriaRuntimeZoneSnapshotCommit.PhysicalPayloads)
- AetheriaRuntimeDroppedPickupCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:771 (via AetheriaRuntimeZoneSnapshotCommit.DroppedPickups)
- AetheriaRuntimeOrbitSnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:805 (via AetheriaRuntimeZoneSnapshotCommit.Orbits)
- AetheriaRuntimeBodySnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:830 (via AetheriaRuntimeZoneSnapshotCommit.Bodies)
- AetheriaRuntimeBodyResourceCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:901 (via AetheriaRuntimeBodySnapshotCommit.Resources)
- AetheriaRuntimeAsteroidCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:911 (via AetheriaRuntimeBodySnapshotCommit.Asteroids)
- AetheriaRuntimeAsteroidMiningAccumulatorCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:936 (via AetheriaRuntimeAsteroidCommit.MiningAccumulators)
- AetheriaRuntimeColorCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1008 (via AetheriaRuntimeGasGiantVisualCommit.Colors)
- AetheriaRuntimeEntitySnapshotCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1024 (via AetheriaRuntimeZoneSnapshotCommit.Entities)
- AetheriaRuntimeFogFieldEmitterCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1198 (via AetheriaRuntimeEntitySnapshotCommit.FogFieldEmitters)
- AetheriaRuntimeEquipmentStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1240 (via AetheriaRuntimeEntitySnapshotCommit.EquipmentStates)
- AetheriaRuntimeLoadoutGenerationSelectionCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1264 (via AetheriaRuntimeLoadoutGenerationReceiptCommit.Selections)
- AetheriaRuntimeEntityContactCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1275 (via AetheriaRuntimeEntitySnapshotCommit.Contacts)
- AetheriaRuntimeEntityStatGridCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1291 (via AetheriaRuntimeEntitySnapshotCommit.StatGrids)
- AetheriaRuntimeActiveConsumableCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1307 (via AetheriaRuntimeEntitySnapshotCommit.ActiveConsumables)
- AetheriaRuntimeConsumableBehaviorStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1330 (via AetheriaRuntimeActiveConsumableCommit.BehaviorStates)
- AetheriaRuntimeBehaviorProgressCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1355 (via AetheriaRuntimeEntitySnapshotCommit.BehaviorProgress)
- AetheriaRuntimeWeaponStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1374 (via AetheriaRuntimeEntitySnapshotCommit.WeaponStates)
- AetheriaRuntimeBehaviorStateCommit Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeSnapshotDocuments.cs:1444 (via AetheriaRuntimeEntitySnapshotCommit.BehaviorStates)
- AetheriaRuntimeStarbridgeStationStockItem Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:71 (via AetheriaRuntimeStarbridgeScenarioDocument.StationStock)
- AetheriaRuntimeStarbridgeWaveDefinition Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:83 (via AetheriaRuntimeStarbridgeScenarioDocument.Waves)
- AetheriaRuntimeStarbridgeWaveForecast Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:94 (via AetheriaRuntimeStarbridgeSessionSummaryDocument.WaveForecast)
- AetheriaRuntimeStarbridgeRuntimeRole Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeStarbridgeDocuments.cs:104 (via AetheriaRuntimeStarbridgeScenarioDocument.RuntimeRoles)
- AetheriaRuntimeAuthorityRuntimeRole Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:194 (via AetheriaRuntimeVerseAuthorityPolicyDocument.RuntimeRoles)
- AetheriaRuntimeAuthorityRule Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:201 (via AetheriaRuntimeVerseAuthorityPolicyDocument.Rules)
- AetheriaRuntimeArenaSeat Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseAuthorityPolicy.cs:255 (via AetheriaRuntimeArenaRosterDocument.Seats)
## AquaSynth (0 element types)
None.
## Aquarium (1 element types)
- AquariumFaceVoiceEndpointState src/Aquarium.Epiphany/State/AquariumLiveState.cs:61 (via AquariumLiveState.FaceVoiceEndpoints)
## Brokkr (4 element types)
- BrokkrGameObjectSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:42 (via BrokkrHostSnapshot.sceneObjects)
- BrokkrComponentSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:62 (via BrokkrGameObjectSnapshot.components)
- BrokkrSerializedPropertySnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:73 (via BrokkrComponentSnapshot.properties)
- BrokkrAssetSnapshot surfaces/unity/Packages/com.gamecult.brokkr/Runtime/BrokkrHostSnapshot.cs:84 (via BrokkrHostSnapshot.assets)
## Delvehold (0 element types)
None.
## Eve (17 element types)
- EveAssetCatalogEntry packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:52 (via EveAssetCatalogDocument.Assets)
- EveAssetVariant packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:89 (via EveAssetCatalogEntry.Variants)
- EveEntitySoaBuffer packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:33 (via EveEntitySoaViewDocument.Buffers)
- EveEntitySoaColumn packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:41 (via EveEntitySoaViewDocument.Columns)
- EveEntitySoaDirtyRange packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:55 (via EveEntitySoaViewDocument.DirtyRanges)
- EveEntityIdentity packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:64 (via EveEntitySoaViewDocument.Identities)
- EveEntityRenderGroup packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:77 (via EveEntitySoaViewDocument.RenderGroups)
- EveInputActionDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:22 (via EveInputCapabilityDocument.Actions)
- EveInputProfileDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:51 (via EveInputCapabilityDocument.DefaultProfiles)
- EveInputBindingDocument packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:59 (via EveInputProfileDocument.Bindings)
- EveProviderWitness packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:249 (via EveProviderAdvertisementDocument.Witnesses)
- EveAdvertisedSurface packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:265 (via EveProviderAdvertisementDocument.Surfaces)
- EveAdvertisedCommand packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:333 (via EveProviderAdvertisementDocument.Commands)
- EveSurfaceComponent packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:106 (via EveSurfaceComponent.Children)
- EveEmbeddedDocumentSlot packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:230 (via EveSurfaceComponent.EmbeddedDocuments)
- EveStyleToken packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:277 (via EveSurfaceTree.Styles)
- EveCommandTemplate packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:295 (via EveSurfaceDocument.Commands)
## EvePlugins (1 element types)
- EveFieldsSplatLayer plugins/eve-plugin-fields/unity/org.gamecult.eve.plugin-fields/Runtime/EveFieldsDocuments.cs:38 (via EveFieldsSplatsDocument.Layers)
## Ghostlight (17 element types)
These types are a vendored copy of Eve (`vendor/eve`); they change when Eve does and the pin bumps.
- EveAssetCatalogEntry vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:52 (via EveAssetCatalogDocument.Assets)
- EveAssetVariant vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveAssetCatalogDocument.cs:89 (via EveAssetCatalogEntry.Variants)
- EveEntitySoaBuffer vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:33 (via EveEntitySoaViewDocument.Buffers)
- EveEntitySoaColumn vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:41 (via EveEntitySoaViewDocument.Columns)
- EveEntitySoaDirtyRange vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:55 (via EveEntitySoaViewDocument.DirtyRanges)
- EveEntityIdentity vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:64 (via EveEntitySoaViewDocument.Identities)
- EveEntityRenderGroup vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveEntitySoaViewDocument.cs:77 (via EveEntitySoaViewDocument.RenderGroups)
- EveInputActionDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:22 (via EveInputCapabilityDocument.Actions)
- EveInputProfileDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:51 (via EveInputCapabilityDocument.DefaultProfiles)
- EveInputBindingDocument vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveInputCapabilityDocument.cs:59 (via EveInputProfileDocument.Bindings)
- EveProviderWitness vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:249 (via EveProviderAdvertisementDocument.Witnesses)
- EveAdvertisedSurface vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:265 (via EveProviderAdvertisementDocument.Surfaces)
- EveAdvertisedCommand vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveProviderDocuments.cs:333 (via EveProviderAdvertisementDocument.Commands)
- EveSurfaceComponent vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:106 (via EveSurfaceComponent.Children)
- EveEmbeddedDocumentSlot vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:230 (via EveSurfaceComponent.EmbeddedDocuments)
- EveStyleToken vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:277 (via EveSurfaceTree.Styles)
- EveCommandTemplate vendor/eve/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs:295 (via EveSurfaceDocument.Commands)
## Gjallar (3 element types)
- GjallarAdvertisedSchema src/Gjallar/VerseState.cs:462 (via GjallarProviderAdvertisementRecord.Schemas)
- GjallarAdvertisedWitness src/Gjallar/VerseState.cs:470 (via GjallarProviderAdvertisementRecord.Witnesses)
- GjallarAdvertisedSurface src/Gjallar/VerseState.cs:480 (via GjallarProviderAdvertisementRecord.Surfaces)
## Heimdall (12 element types)
These types are a vendored copy of CultLib (`vendor/CultLib`); C2a already adds the ids in CultLib, so they resolve when the pin bumps. The witness bundle types are the fork reported with C2a.
- CultGeometryDomainNode vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:61 (via CultGeometryDomainNode.Children)
- CultGeometryFeatureClaim vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:106 (via CultGeometryDomainNode.Claims)
- CultGeometryContributionRow vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:262 (via CultGeometrySelectedCutManifest.Diagnostics)
- CultMeshBodyDescriptor vendor/CultLib/src/GameCult.Mesh/CultMeshBodies.cs:35 (via CultMeshBodyPublicationDocument.Representations)
- CultMeshCdnChunkRef vendor/CultLib/src/GameCult.Mesh/CultMeshCdn.cs:73 (via CultMeshCdnArtifactManifest.Chunks)
- CultMeshDiscoveryCandidateDocument vendor/CultLib/src/GameCult.Mesh/CultMeshDiscoveryService.cs:418 (via CultMeshDiscoveryStateDocument.Candidates)
- CultMeshEntityPrefabComponent vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:81 (via CultMeshEntityPrefabNode.Components)
- CultMeshEntityPrefabAssetRef vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:96 (via CultMeshEntityPrefabPackage.Assets)
- CultMeshEntityPrefabNode vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:127 (via CultMeshEntityPrefabPackage.Nodes)
- CultWitnessContractPin vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:105 (via CultWitnessArtifactBundle.Contracts)
- CultWitnessArtifactEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:136 (via CultWitnessArtifactBundle.Artifacts)
- CultWitnessTimingEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:179 (via CultWitnessArtifactBundle.TimingWitnesses)
## Mimir (0 element types)
None.
## StreamPixels (20 element types)
`CultGeometryDomainNode` is the vendored CultLib copy; C2a already adds its id.
- ActivationCondition apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ActivationCondition.cs:18 (via CharacterConditions.Conditions)
- BattleEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/BattleEffect.cs:8 (via BattleAction.Effects)
- GearStatEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/GearStatEffect.cs:7 (via GearData.StatEffects)
- OverlaySceneIntent apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ServiceLink/OverlayFrameDocuments.cs:33 (via OverlaySceneIntentWindow.Intents)
- PlayedIntentRecord apps/overlay-unity/Assets/Scripts/Assembly-CSharp/ServiceLink/PlayedIntentLedger.cs:25 (via OverlayPlayedIntents.Entries)
- StatModifier apps/overlay-unity/Assets/Scripts/Assembly-CSharp/StatModifier.cs:7 (via GearStatEffect.Modifiers)
- StatusEffect apps/overlay-unity/Assets/Scripts/Assembly-CSharp/StatusEffect.cs:10 (via StatusData.Effects)
- WeaponDamageTypeMultiplier apps/overlay-unity/Assets/Scripts/Assembly-CSharp/WeaponDamageTypeMultiplier.cs:7 (via WeaponData.DamageMultipliers)
- CultGeometryDomainNode vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:61 (via CultGeometryDomainNode.Children)
- CultGeometryFeatureClaim vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:106 (via CultGeometryDomainNode.Claims)
- CultGeometryContributionRow vendor/CultLib/src/GameCult.Geometry/CultGeometryDocuments.cs:262 (via CultGeometrySelectedCutManifest.Diagnostics)
- CultMeshBodyDescriptor vendor/CultLib/src/GameCult.Mesh/CultMeshBodies.cs:35 (via CultMeshBodyPublicationDocument.Representations)
- CultMeshCdnChunkRef vendor/CultLib/src/GameCult.Mesh/CultMeshCdn.cs:73 (via CultMeshCdnArtifactManifest.Chunks)
- CultMeshDiscoveryCandidateDocument vendor/CultLib/src/GameCult.Mesh/CultMeshDiscoveryService.cs:418 (via CultMeshDiscoveryStateDocument.Candidates)
- CultMeshEntityPrefabComponent vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:81 (via CultMeshEntityPrefabNode.Components)
- CultMeshEntityPrefabAssetRef vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:96 (via CultMeshEntityPrefabPackage.Assets)
- CultMeshEntityPrefabNode vendor/CultLib/src/GameCult.Mesh/CultMeshEntityPrefabs.cs:127 (via CultMeshEntityPrefabPackage.Nodes)
- CultWitnessContractPin vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:105 (via CultWitnessArtifactBundle.Contracts)
- CultWitnessArtifactEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:136 (via CultWitnessArtifactBundle.Artifacts)
- CultWitnessTimingEntry vendor/CultLib/src/GameCult.Networking/CultWitnessArtifactBundle.cs:179 (via CultWitnessArtifactBundle.TimingWitnesses)
## Ymir (1 element types)
- YmirTransportLoweringDocument src/Ymir.Core/YmirServicePublicationDocument.cs:63 (via YmirProviderAdvertisementDocument.CommandLowerings)
## CultLib itself
C2a adds ids to: `CultGeometryDomainNode`, `CultGeometryFeatureClaim`, `CultGeometryContributionRow` (`src/GameCult.Geometry/CultGeometryDocuments.cs`); `CultMeshBodyDescriptor` (`src/GameCult.Mesh/CultMeshBodies.cs:35`); `CultMeshDiscoveryCandidateDocument` (`CultMeshDiscoveryService.cs:418`); `CultMeshEntityPrefabAssetRef`, `CultMeshEntityPrefabComponent`, `CultMeshEntityPrefabNode` (`CultMeshEntityPrefabs.cs`); `CultWitnessContractPin`, `CultWitnessArtifactEntry`, `CultWitnessTimingEntry` (`src/GameCult.Networking/CultWitnessArtifactBundle.cs`).
`CultMeshCdnChunkRef` (`src/GameCult.Mesh/CultMeshCdn.cs:73`) has a derived id, `[CultElementId(nameof(Offset))]`: the decimal text of its offset, never random, so packing the same body encodes the same manifest bytes. `contracts/cultmesh/content-vectors.*.json` and the Rust runtime carry it. A consumer with content-addressed elements declares the same way.

View file

@ -3,7 +3,7 @@
CultLib measures its C# test suites with Stryker.NET, pinned as a repo-local
tool in `dotnet-tools.json`. Coverage is added project by project; a project
with no `stryker-config.json` next to its test project has not been scoped
yet (see "Scope and follow-ups" below for what's outstanding).
yet (see "Follow-ups" below for what's outstanding).
## Running it
@ -126,17 +126,14 @@ project as precedent for the `vstest` caveat itself - that caveat is
`mtp`-runner-specific and documented above for `static readonly` field
initializers, and GameCult.Geometry has never actually hit it.
## Scope and follow-ups
## Follow-ups
CultMath.Tests, GameCult.Geometry.Tests and GameCult.Caching.Tests are the
projects currently scoped. GameCult.Geometry's `stryker-config.json` has no
`test-runner` entry: the project is NUnit 4 with `NUnit3TestAdapter` and
CultMath.Tests and GameCult.Geometry.Tests are the only projects currently
scoped. GameCult.Geometry's `stryker-config.json` has no `test-runner`
entry: the project is NUnit 4 with `NUnit3TestAdapter` and
`Microsoft.NET.Test.Sdk`, so Stryker's default `vstest` runner applies
directly (no `mtp` workaround needed, unlike CultMath.Tests's xunit.v3
suite). GameCult.Caching.Tests is the same shape (NUnit, default `vstest`)
and its `stryker-config.json` is driven the same way, by the repo-local
`dotnet-stryker` tool: `dotnet tool restore` once, then `dotnet tool run
dotnet-stryker` from `tests/GameCult.Caching.Tests`.
suite).
GameCult.Geometry has only ever been run `--since:`-scoped to the cut that
added surface nets; a full-project baseline (running mutation against all of
@ -154,83 +151,7 @@ file, anything under `Fixtures/`) loses `--since` line-level scoping for that
run; a future cut whose diff is C#-only, against a base commit that already
has `stryker-config.json`, should scope correctly.
### GameCult.Caching.Tests
`tests/GameCult.Caching.Tests/stryker-config.json` mutates all of
`src/GameCult.Caching` (the project is `GameCult.Caching.csproj`; Standard
mutation level, `break` 0, the same thresholds as Geometry). Caching was
scoped before Networking because its suite is deterministic and quick (1415
tests, about 40 s) over about 7.8k lines of source, where GameCult.Networking
is about 20k lines and its tests lean on socket timing, so a mutant timing out
there says little about the test. A full Caching run is about 2,900 candidate
mutants (3,602 created, minus 730 that fail to compile) at 1.25 to 1.6 s
each, so about 1 to 1.3 hours; run it detached and read the JSON report,
never as one attached session.
The first measurement was a narrow pass over the three files that carry the
store-replace and element-id machinery, from `tests/GameCult.Caching.Tests`:
```bash
dotnet tool run dotnet-stryker \
-m '**/src/GameCult.Caching/StoreReplace.cs' \
-m '**/src/GameCult.Caching/CultStoreLinks.cs' \
-m '**/src/GameCult.Caching/CultElementIds.cs'
```
A clean single run of that command tests 243 to 265 mutants (the count moves with the revision) and scored 85.28% on
568a60fa, before any test of this scope was added; 86.79% on d38b5728, with
the first batch of tests; and 87.17% on 76d9e7d1, with the throwing-enumerator
case, in about 10 minutes. The first measurement of it overlapped two Stryker
runs, so its numbers (and the 114 survivors it showed for `CultElementIds.cs`,
where tests that kill a mutant by hand reported `Survived` or `Timeout`) are
not measurements and are not quoted here. Never run two Stryker runs against
one checkout at once, and check `ps` before starting one. A clean single run
on `CultElementIds.cs` alone, after the tests below, tested 228 mutants: 208
`Killed`, 8 `Timeout`, 12 `Survived`, a score of 94.74%. The 12th survivor
was 321, which the throwing-enumerator case in `ListsOfValuesHoldNoIds` now
kills; the other 11 are equivalent mutants. With that case a clean single run
on `CultElementIds.cs` is 209 `Killed`, 8 `Timeout`, 11 `Survived`, 95.18%.
Real gaps that run exposed, each now pinned by `ElementIdWalkTests` (and each
failing when its mutant is applied by hand): the order of the id path (the
codec refuses at the first type MessagePack cannot allow, so order is what an
error names first; `Append` to `Prepend` at 163, 167 and 180); the
union-cycle guard in `ConcreteTypes` (248; the mutant is a stack overflow, so
the red is a crashed test host: a depth guard would need production code in
`ConcreteTypes`, since the recursion is private and a stack overflow cannot be
caught by the test, so the regression test is left to crash loudly); a list of values reporting that it holds an
id (`Holds`, 322); `[Key]` read through an override (`KeyOf`, 583); and the
`IsWrittenWhole` and `IsKeyed` predicates (629 to 631 and 636: formatter,
`[MessagePackObject]` and `KeyAsPropertyName` inherited from a base, and the
`[DataContract]` without `[MessagePackObject]` and `||` arms).
Equivalent survivors in `CultElementIds.cs` (11), each hand-checked against the full
suite; none can be told apart by any test:
- 142, 251, 636 (second arm), 506 (`IsDefined(UnionAttribute, false)` to
`true`): `UnionAttribute` is `Inherited = false`, so its `inherit` argument
changes nothing. Likewise 630's `DataContractAttribute` `inherit` argument
(`DataContractAttribute` is `Inherited = false`).
- 264, 542: `group.First()` to `FirstOrDefault()` over `GroupBy` groups, which
are never empty.
- 491: `new StringBuilder(bytes.Length * 2)` to `/ 2` changes only the initial
capacity.
- 578: removing the `Nullable.GetUnderlyingType` unwrap in `WalksAsOwner`;
`Classify` unwraps nullables itself, so the unwrap is dead. A cut
candidate, not a test gap.
- 645, 657: `Append(type)` to `Prepend(type)` in `ElementTypeOf` and
`MapTypes`; `type` matches only when it is itself `IEnumerable<>`,
`IDictionary<,>` or `IReadOnlyDictionary<,>`, and none of those has a second
matching interface, so the position of `type` never decides the answer.
The Windows-only P/Invoke group is exercised only on Windows: its mutants are
`NoCoverage` on Linux and are a target for a Windows-leg mutation run, not a
Linux gap or a finding. `StoreReplace.cs` line 45 is a different case: its
negated `IsOSPlatform(Windows)` mutant is `Survived` on Linux, because the
negated branch calls `ReplaceWindows`, whose `DllNotFound` fallback gives the
same result.
`GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
`GameCult.Caching`, `GameCult.Mesh` (+ `.Quic`, `.Quic.Native`),
`GameCult.Networking` (+ `.WebSockets`), and the non-.NET runtimes
(`cultcache-rs/py/ts`, `cultmesh-*`, `cultnet-*`) each still need their own
`stryker-config.json` (or ecosystem-equivalent tool: `cargo-mutants` for

View file

@ -25,13 +25,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-v3.0.0
https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v2.0.0
```
## Rolling back across the store format
A consumer that upgrades and writes a store holding an element id leaves a
`cultcache.store.v3` single-file store (`v5` directory store). Refusal by name depends on the store kind and the pin. A **directory** store (`v5`) is refused by every Unity pin from `cultlib-unity-v1.0.57` and `caching-unity-v1.2.0` on (the earliest tags of each line whose `DirectoryMessagePackBackingStore` reads only the `v4` stamp: "only ... is readable"). A **single-file** store (`v3`) is refused only from `cultlib-unity-v2.0.0` and `caching-unity-v2.0.0`: `cultlib-unity-v1.0.61` and `caching-unity-v1.4.0` read the header string and never compare it. The NuGet line (`cultlib-v1.0.32`, the last `cultlib-v` tag) refuses neither: its directory store writes `cultcache.store.v1.directory` and never checks the stamp on read. Rolling back a single-file store to any of those pins reads it silently, drops the ids, and can rewrite the elements without them. That is the dangerous case. There is
no general carry-back; restore from a copy taken before upgrading, and upgrade
readers before writers. Detail: the Store format and rollback section of
`src/GameCult.Caching/README.md`.

View file

@ -1,59 +0,0 @@
# Consumer pinning at a breaking land: prior art and internal state
Serves fork `variants:question:unpinned-consumers-at-land` (campaign `variants`, session `self-2026-10-22-eureka-c2a`).
Eyes pass, 2026-10-22. Facts and evidence pointers only; no recommendation.
Question as admitted: Eve.Surface and Brokkr build against an unpinned sibling `..\CultLib` source checkout. C2a (refuse element types with no id) lands on main. Options: pin-then-land, sweep-then-land, land-and-break.
## Sources
| # | Source | URL | Status |
|---|---|---|---|
| S1 | Abseil, About / philosophy | https://abseil.io/about/philosophy | fetched |
| S2 | Abseil, compatibility guidelines | https://abseil.io/about/compatibility | fetched |
| S3 | Software Engineering at Google, ch. 21 Dependency Management | https://abseil.io/resources/swe-book/html/ch21.html | fetched |
| S4 | Software Engineering at Google, ch. 16 Version Control and Branch Management | https://abseil.io/resources/swe-book/html/ch16.html | fetched (does not cover live at head) |
| S5 | Russ Cox, Minimal Version Selection | https://research.swtch.com/vgo-mvs | fetched |
| S6 | Cargo book, Cargo.toml vs Cargo.lock | https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html | fetched |
| S7 | npm docs, package-lock.json | https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json | fetched |
| S8 | Chromium src, dependencies doc (DEPS, gclient, roll-dep) | https://chromium.googlesource.com/chromium/src/+/main/docs/dependencies.md | fetched |
| S9 | Pro Git, Submodules | https://git-scm.com/book/en/v2/Git-Tools-Submodules | fetched |
| S10 | chromium infra dependencies.md | https://chromium.googlesource.com/infra/infra/+/main/docs/dependencies.md | 404, not retrieved |
| L1 | `F:\Projects\Aetheria\Directory.Build.targets`, `Directory.Build.props`, git history | local files | read |
| L2 | `F:\Projects\Aetheria\docs\cultcache-migration-cut.md` (Q6 table, lines ~565-580; pin guard notes ~1900-1925) | local file | read |
| L3 | `F:\Projects\Eve` and `F:\Projects\Brokkr` csprojs and git file lists | local files | read |
| L4 | voidbot `search_history` (two queries), `search_sources` (one query) | MCP | queried |
Anything not in a "fetched" row above is not claimed from the web.
## 1. Established practice: who pays for a breaking upstream change, and when
- **Abseil / live at head (S1, S2).** Recommends users track the latest code. Says it will provide a tool to perform an API refactor for well-behaved code (S1). Requires of downstream: show how you call it, stay within the compatibility guidelines; otherwise fork or use periodic supported branches (about every 6 months, patched 2 years) (S1). Says every change is potentially breaking for someone; breaks are announced in commit messages and LTS notes, and users are asked to report undetected breaks on GitHub (S2). Users should be able to temporarily pin to an earlier commit if a newer change is a problem (S2). S1/S2 do not mention Rosie, the Beyonce rule, or caller-fixing; the fetched pages describe tooling and a feedback channel only.
- **Live at Head as described in SWE at Google ch. 21 (S3).** Providers are expected to test changes against downstream dependents before committing; breaking changes wait until dependents are updated or an automated migration tool exists. Consumers must keep meaningful tests. Stated preconditions: unit tests across dependencies run in CI, a visible reverse-dependency graph, substantial compute, no pinning of dependencies, provider acceptance of the downstream-testing duty. Called "costly but effective" inside Google and "largely unproven at scale." Notes that under SemVer a major bump costs maintainers little and many indirect users a lot. Ch. 16 (S4) does not address live at head; it states large-scale changes across trunk are costly and tooling-supported.
- **Go MVS (S5).** Selects the oldest version satisfying all declared minimums. Publishing a new version changes nobody's build; a consumer gets it only when a requirement names it, directly or through another module. Upgrades are explicit edits to `go.mod` by the top-level module. Compatibility rule: newer versions inside one major version are expected to work where older did; a major version is a different module identity. Cost of a breaking change falls on whoever opts into the new version, at the time they opt in.
- **Cargo (S6).** Git dependency without `rev` takes the default-branch tip at first build and fixes that commit in `Cargo.lock`; changes arrive only on `cargo update`. A `rev` in `Cargo.toml` pins explicitly; the Cargo book calls hand-managed SHAs "tedious and error prone" and prefers the lockfile. Consumer pays at `cargo update` time.
- **npm (S7).** `package-lock.json` records the exact tree; installs stay on it until the lockfile changes and is committed. It is not published with a package. The page gives no timeline for when updates reach a consumer.
- **Chromium DEPS (S8).** `DEPS` stores dependency URLs and commit hashes (often in `vars`). Developers roll with `gclient setdep`, `roll-dep`, or manual `git update-index`; some projects (Skia) are autorolled. The page says manual rolling after an upstream change is good practice to confirm the roll compiles and tests pass. It does not describe revert procedure. Cost is paid by the roller at roll time.
- **Git submodules (S9).** The superproject records one commit SHA (gitlink, mode 160000). Upstream commits never move the pin; it moves when someone updates the checkout and commits the new SHA in the superproject. Collaborators receive the pin via pull and must run `git submodule update`.
## 2. Internal precedent
- **Aetheria pin (L1).** `Directory.Build.props` holds `CultLibRevision` (currently `45c2f4006f5f7dab357426cabd3197e8d0a17f66`) and `CultMathRevision` (`9c1b06cd703bfa44352050e819b0d0659cf5978a`). `Directory.Build.targets` target `VerifyCultLibRevision` (BeforeTargets `ResolveProjectReferences`) errors if the sibling checkout's `git rev-parse HEAD` differs from the pin, or if `git status --porcelain` is non-empty; escape hatch `SkipCultLibRevisionCheck=true`. File is 39 lines; the pin is one pair of properties per pinned package.
- **Why added (L1 git).** Commit `4f4beb0f` (2026-09-14, "Pin CultLib for headless builds"): "Directory.Build.targets carries Delvehold's VerifyCultLibRevision guard, checking for CultMath.csproj: HEAD must equal the pin and the worktree must be clean." Commit `e91ba369` (2026-09-24, "Pin CultLib per package, as Unity's manifest does"): headless build must compile the revisions Unity consumes; a single pin would have compiled six commits of newer CultCache under headless tests while Unity stays on its tag. The Aetheria cut doc (L2, ~line 1905) calls the guard "the only working pin for a sibling checkout." Aetheria's `Packages/manifest.json` pins Unity packages by git tag.
- **Pin guard already in Delvehold (L4/search_sources hit, `Delvehold:Directory.Build.targets`)** and a variant in AetheriaEve (archived, `Aetheria.State.Dependencies.targets`, also checks a Unity package tree hash). Delvehold's guard checks `GameCult.Mesh.csproj` exists, HEAD equals `CultLibRevision`, tree clean. Date of Delvehold's first guard: not determined.
- **Consumer table (L2, Q6 in Aetheria `docs/cultcache-migration-cut.md`).** Aquarium, Ymir, AquaSynth, Mimir, Gjallar: ".NET sibling, unpinned", compile against main at once. Brokkr, Eve, EveUnity, EvePlugins: "Unity package / sibling", pin "various". Delvehold: sibling with revision guard at `334e60f`, 52 behind, "Not until it re-pins." Heimdall: submodule `b6b1d9c`. Rust consumers (Idunn, Epiphany, Odin, Ghostlight, Muninn, Ratatoskr, CodexConnector): Cargo by commit.
- **Operator discussion (L4).** voidbot `search_history` ("pin CultLib revision vs track main for consumers"; "Brokkr Eve sibling CultLib checkout build pin") returned no relevant operator statement on pinning versus tracking; hits were unrelated Discord chatter. Nearest operator words found in the Discord index: Metacrat, 2026-05-09, "Oh, anyone using CultLib will still be paying the blood price, you can count on that" (message 1502671320406687894; context not fetched, so its subject is not confirmed). No operator quote on pinning versus tracking was found in memory files grepped (`C:\Users\Meta\.claude\projects\F--Projects\memory`, pattern pin/track main/unpinned/sibling) beyond a retired Idunn pin line and Odin/Muninn pin-branch coordination notes.
- Aetheria cut doc operator mentions near pins are about content (Schematic-shape drawer, 2026-09-14; Studio grouping, 2026-09-17), not pinning policy.
## 3. Current state of the two consumers (L3)
- **Eve.** `packages/org.gamecult.eve.surface/GameCult.Eve.Surface.csproj:16` `CultLibRoot` defaults to `$(MSBuildThisFileDirectory)..\..\..\CultLib`; lines 20-21 ProjectReference `$(CultLibRoot)\src\GameCult.Caching\...` and `...\GameCult.Mesh\...`. `tests/GameCult.Eve.Surface.Serialization.Tests/*.csproj:19` references `$(CultLibRoot)\src\GameCult.Mesh\...` (and Eve.Surface by relative path). `git ls-files` shows only `packages/org.gamecult.eve.surface/Directory.Build.props` (sets only BaseOutputPath/BaseIntermediateOutputPath/MSBuildProjectExtensionsPath, no revision). No `Directory.Build.targets`, no `global.json`, no `.gitmodules`, no revision property or check. Eve also holds Cargo.toml/Cargo.lock (Rust side, not examined).
- **Brokkr.** `brokkr-command/Brokkr.Command.csproj:7`, `brokkr-contracts.Tests/Brokkr.Contracts.Tests.csproj:7`, `brokkr-contracts/Brokkr.Contracts.csproj:8`: `CultLibRoot` defaults to `..\..\CultLib`. ProjectReferences: Caching, Caching.MessagePack, Mesh, Networking (by project). No `Directory.Build.*`, no `.gitmodules` in `git ls-files`; no revision property or check.
- Neither repo contains `CultLibRevision`, a `VerifyCultLibRevision` target, or any HEAD check. Both build whatever `F:\Projects\CultLib` currently holds (including dirty or other-branch states).
- Fork text claims (not re-verified here): Eve.Surface has 0 `[CultElementId]`; Brokkr `[CultDocument]` files show no object lists by grep.
## Gaps
- S10 (Chromium infra) 404; Chromium autoroller behavior beyond S8 not fetched.
- Rosie / Beyonce rule not found in fetched pages; not cited from memory.
- No operator statement on pin-versus-track found in voidbot or the memory grep.

View file

@ -1,74 +0,0 @@
Serves the C2a fork on finding variants:finding:cut-land-c2a-gate.s1.reflist-record-read-only-at-head (campaign variants), 2026-10-23.
# Prior art: read with an old writer schema, then write back
Facts only. Pages were fetched 2026-10-23 through a summarising fetcher; "not stated" means the fetched page did not say it, not that no source does. Items marked (memory) are my background knowledge, not verified from a fetched page.
## 1. Apache Avro
- Source: https://avro.apache.org/docs/current/specification/ (Schema Resolution; summarised via apache/avro doc source), Java `DataFileWriter` javadoc (1.11.1).
- Model: the writer schema travels with the data. The reader resolves it against its own reader schema. Resolution is a read-time projection; the spec defines no write-back.
- Data loss on projection: "the writer's value for that field is ignored" when the reader schema lacks the field. A reader-only field takes its default, or the read errors if it has none.
- Write-back: the spec states no rule. Object container files store one schema in the header ("Each datum in a file is of the same schema"). `appendTo` is documented only as "Open a writer appending to an existing file"; it does not say which schema the append uses. (memory: Java `appendTo` reuses the file's header schema, so an append never restamps.)
- Stated reason for the read-time design: readers and writers evolve separately (fields added or removed in a different software version).
## 2. Protocol Buffers (proto3)
- Source: https://protobuf.dev/programming-guides/proto3/ (Unknown Fields).
- On write: unknown fields are "included during parsing and in the serialized output" (matches proto2). Round-trip keeps fields the current type does not know.
- Loss cases the page lists: serializing to JSON, and copying a message field by field into a new message (it recommends `CopyFrom`/`MergeFrom`).
- No schema id or version is written. The wire format carries only field numbers, so "restamp" has no meaning here.
- Not stated: behaviour in versions before 3.5 (memory: proto3 dropped unknown fields before 3.5, then reversed). The Java reference page says nothing on preservation.
- Stated reason: preserve data across a mixed-version fleet.
## 3. Confluent Schema Registry
- Source: https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
- BACKWARD (default): consumers on the new schema read data written with the new or the previous version. FORWARD: data written with the new schema is readable by consumers on the old one. FULL: both.
- Write: "Each schema version gets a unique ID and an incremented version number." A producer writes under the schema it holds (its own current version). The id is stamped per message at serialisation, from the producer's schema.
- Upgrade order is how the rules prevent loss. BACKWARD means upgrade consumers before producers. FORWARD means upgrade producers first, and keep old-schema data away from consumers until they are upgraded.
- Not stated on the page: what a consume-modify-produce app writes. (memory: it re-serialises under its own latest schema, so fields it does not model are lost unless a compatibility rule kept them.)
- The registry gates registration. It does not gate individual rewrites.
## 4. Document DBs and ORMs with stored versions
### MongoDB schema-versioning pattern
- Sources: https://www.mongodb.com/blog/post/building-with-patterns-the-schema-versioning-pattern ; https://www.mongodb.com/docs/manual/data-modeling/design-patterns/data-versioning/schema-versioning/
- Documents carry `schema_version` (absent means 1). The app keeps a handler per version and writes new documents at the current version.
- Migration strategy is the app's choice: migrate all, migrate "when accessed" (lazy), or never.
- Not stated: whether an update of an old document restamps it. The manual's update example branches on `schemaVersion` and writes into each version's own shape. It notes its `else: null` branch adds stray null fields to the other shape.
- Stated reasons: no downtime, failed migrations easier to handle, timing flexibility. Cost: handler code per live version.
### Realm (Atlas Device SDKs, now deprecated)
- Source: https://www.mongodb.com/docs/atlas/device-sdks/sdk/swift/model-data/change-an-object-model.md
- Per-file schema version. A changed schema needs an incremented `schemaVersion`, plus a `migrationBlock` for non-trivial changes. The file is migrated whole at open, before any read or write, and Realm "will throw an error" if the version is lower or the same but the schema differs.
- No lazy per-object restamp. Writes happen only on a migrated file.
- Data loss: the manual says "Deleting a property will not impact existing objects", i.e. removal is automatic and needs no developer step. Required new properties and renames need a manual block. (memory: removed-property values are dropped from the file.)
### Core Data
- Source: web search results only (Apple forum threads 745114 and 785427, WWDC22-10120, "staged migrations" doc). The Apple docs URL I tried returned 404.
- A store whose model hash differs from the current model fails to open with an incompatible-version error. Opening migrates the whole store when `NSMigratePersistentStoresAutomaticallyOption` and `NSInferMappingModelAutomaticallyOption` are set. Lightweight migration covers only obvious patterns (add entity, remove relationship, required to optional, rename). Anything else needs a staged or custom migration.
- Migration is store-wide at open. It does not happen per record on write.
### EF Core
- Not fetched. (memory: no per-row schema stamp. Migrations are explicit, ordered DDL, tracked in a `__EFMigrationsHistory` table.)
## 5. Old-schema data held read-only until explicit migration
### Elasticsearch archive indices
- Sources: https://www.elastic.co/docs/deploy-manage/upgrade/deployment-or-cluster/reading-indices-from-older-elasticsearch-versions ; https://www.elastic.co/guide/en/elasticsearch/reference/current/archive-indices.html ; 8.0 migration notes (7.0+ indices readable, 6.x or earlier must be reindexed).
- Indices from an older major are readable through search and field-caps. "They do not support the Get API or any write APIs."
- The route to a writable copy is reindex into a new index that is "fully compatible with the current Elasticsearch version". `_source` is readable, so no data is lost on reindex.
- Stated reason: the page does not say why they are read-only. It describes archive access as "slower read-only access", with limited query capability. Some legacy mappings cannot be imported (the original is kept in `_meta`), and that is the one place the page shows a lossy translation.
- Third-party (Search Guard blog, unverified against Elastic): in 9.x a plain `index.blocks.write` does not satisfy the boot check; `index.verified_read_only` is needed.
- This is the closest match to option (b) in the fetched set: an old format, readable but not writable, left until the owner reindexes it.
## Where restamp-on-write loses data (from the sources above)
- Avro: a field present in the writer schema and absent from the reader schema is dropped at read, "ignored". Any rewrite from the resolved value would omit it.
- Protobuf: unknown fields survive a binary round trip. They are lost via JSON, or when copied field by field.
- Realm: removed properties are deleted by the automatic migration.
- MongoDB: a handler that writes only the current shape can lose or relocate old-shape fields (the manual's example adds nulls).
- Confluent: loss is prevented by compatibility rules at registration time, not at each write.
## Pattern across the set
- No fetched system restamps a single record on write when the record's writer schema differs from the application's type, on an app-visible, per-record basis.
- Avro, protobuf and Confluent carry the schema per file or message.
- Realm, Core Data and Elasticsearch move or block the whole store or index at open.
- MongoDB leaves it to the app.
- Whole-store migration at open is the usual explicit step (Realm, Core Data). Elasticsearch is the one case of read-only-until-reindex.
- Not found: a system that declares per-schema-id compatibility, as in option (b). Avro schema resolution and Confluent compatibility rules are the nearest analogues. Both are read-side or registration-side declarations.

View file

@ -1,53 +0,0 @@
Serves question variants:question:store-hold-user-code (campaign variants), 2026-10-23.
# Prior-art check: user code under a lock
Facts and pointers only. Each source was fetched or searched this session; what was not reachable is marked.
## 1. Goetz et al., Java Concurrency in Practice (Addison-Wesley, 2006), ch. 10 "Avoiding Liveness Hazards"
Status: EXISTS, SECTION NUMBER CORRECTED.
- "Open Calls" is **10.1.4**, not 10.1.3. 10.1.3 is "Deadlocks Between Cooperating Objects"; 10.1.2 is "Dynamic Lock Order Deadlocks"; 10.1.5 is "Resource Deadlocks". Source: text extracted from a PDF copy of the book (its TOC lists Listing 10.6 "Using Open Calls to Avoiding Deadlock Between Cooperating Objects", p. 133). The copy is an unofficial one at library.megu.edu.ua; a publisher link was not checked.
- (a) 10.1.3 gives the warning sign: an "alien method" (defined p. 40 as a method whose behavior the calling class does not fully specify) called while holding a lock. Paraphrase: such a call may take other locks (deadlock risk) or block a long time, stalling threads that need the lock you hold. 10.1.4 defines an open call as a call made with no locks held (credited to Lea, CPJ 2.4.1.3) and says programs restricted to open calls are far easier to analyze for deadlock-freedom than ones that allow alien calls under lock.
- Cost it names: moving a call out of a synchronized block makes an atomic operation non-atomic (its getImage example stops being a snapshot). Where atomicity is needed it suggests structuring so only one thread can run the code path after the open call (service shutdown example).
- (b), (c): not addressed in this section.
- Pointer: JCIP 10.1.3 and 10.1.4, pp. ~132-134 (chapter 10 starts p. 128).
## 2. Sutter, "Effective Concurrency: Avoid Calling Unknown Code While Inside a Critical Section"
Status: VERIFIED, with date caveat.
- Dr. Dobb's column in the "Effective Concurrency" series. Sutter's blog teaser is dated 2007-11-06: https://herbsutter.com/2007/11/06/effective-concurrency-avoid-calling-unknown-code-while-inside-a-critical-section/ . Original link ddj.com/architect/202802983; Dr. Dobb's mirror https://drdobbs.com/cpp/avoid-calling-unknown-code-while-inside/202802983 (4 pages). The Dr. Dobb's issue date was not shown in anything fetched; the blog date is the only date confirmed. www.drdobbs.com did not resolve from this machine, so the column body comes from search-result summaries and the blog teaser, not a full first-hand read.
- (a) Core claim: locks do not compose; two separately correct modules can deadlock when combined. Trigger: caller inside a critical section, callee enters another critical section or blocks, another thread can take the same two in the opposite order. "Unknown code" listed as: library functions, plug-ins, callbacks/function pointers, virtual methods, generic types.
- Mitigations listed: move the call outside the critical section; pass copies instead of shared data; narrow the critical section (finer locks, reader-writer); require callees not to block (documentation/discipline only).
- (b), (c): not addressed.
## 3. Linux lockdep
Status: VERIFIED, with a nuance.
- Source: kernel docs "Runtime locking correctness validator", https://docs.kernel.org/locking/lockdep-design.html .
- (b) Lockdep tracks lock classes, not instances, and records dependencies between classes as sequences are observed. It reports a circular order as a potential deadlock even if the deadlock never happened. Its "Proof of 100% correctness" section says that once each simple single-task locking sequence has occurred at least once, no timing of them can deadlock. Each new lock chain is validated once and hashed; repeats skip validation.
- Nuance: it flags the dependency edge that closes a cycle, so both orders must have been exercised at some point (possibly by different, non-concurrent runs). A lone nesting is flagged only if it breaks a single-lock rule (IRQ-safe vs unsafe, same-class recursion). "Refuses on first sight" fits the closing edge or a single-lock rule violation only. It warns; it does not block the acquisition.
## 4. ConcurrentHashMap re-check after locking
Status: VERIFIED.
- Source: OpenJDK java/util/concurrent/ConcurrentHashMap.java, `putVal` and `computeIfAbsent`: head node `f` is read unlocked, `synchronized (f)` is taken, then `tabAt(tab, i) == f` is re-checked; on failure nothing is done and the loop retries. The overview comment gives the reason: the locked node can be replaced, so first-in-bin must be confirmed after locking. (Only the first 100k of 269k chars were read; both methods were reported found there.) Empty-bin `computeIfAbsent` uses a ReservationNode plus CAS instead.
- (c) This is the pattern: choose the lock from an unlocked read, validate the read after acquiring.
## 5. LMDB
Status: VERIFIED (paraphrase). Not about alien code or lock order.
- Source: lmdb.h, `mdb_txn_begin` and `mdb_env_open` (https://github.com/LMDB/lmdb, libraries/liblmdb/lmdb.h). A thread may hold one transaction at a time (plus children); a transaction and its cursors belong to one thread; one write transaction at a time, so writers cannot deadlock each other; MDB_NOTLS ties reader slots to the txn object instead of the thread, and the application must then serialize writers itself because write locking is unaware of user threads.
- Relevance: "one hold per thread" as a deliberate rule, parallel to CultCache's one-cache-gate-per-thread.
## 6. SQLite btmutex.c
Status: VERIFIED (summary of trunk source).
- Source: https://github.com/sqlite/sqlite/blob/master/src/btmutex.c (read via raw.githubusercontent.com; sqlite.org served a bot check).
- (b) Prevention by ordering, not detection: sharable B-trees on a connection sit in a list sorted by BtShared address and are always locked ascending (`sqlite3BtreeEnterAll`). `sqlite3BtreeEnter` try-locks first; on failure it releases later-ordered locks, waits, then reacquires in order. `wantToLock` is a count that makes a non-recursive mutex behave recursively.
- No alien-code or callback discussion in these comments.
## Counter-positions and how they stay safe
- Callbacks under lock, documented: Java `ConcurrentHashMap.compute`/`computeIfAbsent`. Javadoc (Java 21): the function should be short and simple because other threads' updates may block meanwhile, and must not modify the map. The javadoc does not say "internal lock"; that it runs under the bin lock is inferred from the source in item 4. Safety rests on documented restrictions, not enforcement.
- Opposite choice: .NET `ConcurrentDictionary.GetOrAdd(key, valueFactory)`. Writes use fine-grained locks but valueFactory runs outside them, "to avoid the problems that can arise from executing unknown code under a lock" (doc remarks). Price: not atomic; the factory may run more than once; a recheck decides the winner (another thread's insert wins). https://learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2.getoradd . Same shape as option `encode-first` (user code outside the lock, then recheck and commit) and as item 4.
- JCIP and Sutter both name the cost of leaving the lock: lost atomicity. Sutter also offers "pass copies" and "callee must not block" as alternatives to leaving.
- Not researched (no source fetched): Linux notifier chains, Java `synchronized` event listeners, other systems that call back under lock by design. No verified citation for them.
## Unverified / not done
- JCIP: section numbers and text come from one PDF copy, not the publisher edition.
- Sutter: full column body not read first-hand.
- No Discord or voidbot content read.

View file

@ -1,90 +0,0 @@
# Prior art: a process-global default registry deciding write ownership
Serves `variants:question:registryless-write-reads-shared` (campaign `variants`). Eyes pass, session `self-2026-10-27b-eureka`, 2026-10-27. Facts only; items marked "summary" rest on search summaries not opened.
# Prior art: process-global type registries and write/overwrite ownership
Fork: `variants:question:registryless-write-reads-shared` (viewed in full via eureka-state, status InForce, options `shared-decides` / `written-type-only`, recommended `shared-decides` at ~65%).
Facts only. No conclusion. Date 2026-10-27.
Method note: web pages were read through WebFetch (a small model summarises the page) and WebSearch summaries. Quotes below are what those tools returned; where only a summary was available it is marked "summary". Nothing was run.
## A. Local mechanics (CultLib, read-only, `C:\cl-wt\c2a-land\src\GameCult.Caching\CultCache.cs`)
- `CultDocumentRegistry.Shared` is `Lazy<CultDocumentRegistry>` (line 639-645). Its public constructor calls `Refresh()` (647-650), which scans `ReflectionExtensions.GetAttributedDocumentTypes()` (688; defined in `ReflectionExtensions.cs:10`) and rebuilds the indexes under `_registrationGate`. So Shared's initial content is whatever `[CultDocument]` types are in loaded assemblies at first touch of `Shared`.
- `GetRequired(Type)` (697-711) registers an unregistered `[CultDocument]` type on demand. So Shared also grows whenever any caller asks for a type. `Refresh()` is public and rebuilds the index from currently loaded assemblies.
- `ForTypes(IEnumerable<Type>)` (658-669) builds a registry with `discoverLoadedDocuments:false`, containing only the given types (plus those `GetRequired` pulls in).
- `CultCache(registry = null, ...)` sets `_registry = registry ?? CultDocumentRegistry.Shared` (2202-2205).
- `WriteDocument<TDocument>(key, doc, storedAt, registry = null)` (4515-4545): `resolvedRegistry = registry ?? Shared`; `catalogEntry = resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry()`; then `Cacheless(resolvedRegistry, ...)` lends that registry to the store for the call. XML doc (4510) says Shared "can only refuse more".
- `WriteRecord(..., registry = null)` (4491-4497): XML doc (4489) says that with registry omitted "it decides nothing for copied records and the one entry is the only claimant".
- `OwnsOnDisk(upserts)` (4741-4759): when the store is cacheless and upserts are given, ownership of an on-disk record is decided by `CultDocumentRegistry.Owns(id, catalog, declared, new CatalogEntryLookup(writes, AttachedRegistry), ..., claimant => writes.Any(entry => entry.SchemaId == claimant.SchemaId))`. The comment (4731-4735, 4738-4740) says written entries shadow the lent registry's type for their ids, the registry adds other claimants, and the record is owned only when the claimant is a written entry, "a registry can only refuse more". `CatalogEntryLookup` ctor at 604 takes `(entries, CultDocumentRegistry? others)`.
- Net: with `registry == null` the lent registry's claimant set is Shared's current contents, which depend on assemblies loaded and on every prior `GetRequired` call in the process.
## B. Q1. Process-global, lazily filled registries in established libraries
### MongoDB C# driver, `BsonClassMap`
- Global: yes. `BsonClassMap.RegisterClassMap` is process-wide static; unregistered types are auto-mapped on first use. Docs: "You must register a class map _before_ it's needed in your code." and "We recommend registering class maps before initializing a connection with MongoDB." https://www.mongodb.com/docs/drivers/csharp/current/serialization/class-mapping/ (WebFetch; the page itself says nothing on freezing or late-registration errors).
- Behaviour depends on what has been touched: EF Core provider issue EF-88 reports "Unknown discriminator value 'List`1'" when the first operation after startup is a read; reporter links it to a class map that is created only on write. Status Closed/Fixed in 8.0.0; page has no maintainer comment. https://jira.mongodb.org/browse/EF-88 (WebFetch). Reporter says explicit `RegisterClassMap` at startup did not fix it for them.
- A MongoDB community answer (via search summary, not opened): discriminator mappings persist once registered, so a read after an insert works; fix offered is `[BsonKnownTypes]`. https://mongodb.com/community/forums/t/c-driver-why-is-bsonknowntypes-attribute-needed-for-polymorphism/2183 (summary only).
- Register-once contract: CSHARP-4424 states the three `RegisterClassMap` methods are "meant to only be called once for a given type"; requested because several libraries share data models and do not control init order. Closed Done, fix version 2.19.0; the page does not say which API shipped. https://jira.mongodb.org/browse/CSHARP-4424 (WebFetch). Community workaround named in search summary: `BsonClassMap.IsClassMapRegistered`.
- CSHARP-1879 (search summary): `RegisterClassMap` accepted an unfrozen map and failed later on use; fixed 2.4.2. https://jira.mongodb.com/browse/CSHARP-1879 (not opened).
- CSHARP-5597 (search summary): `BsonSerializer.RegisterDiscriminator` has no effect; discriminator dictionary populated at class-map registration. https://jira.mongodb.org/browse/CSHARP-5597 (not opened).
- Polymorphic docs: "You must specify hierarchical discriminators in root-to-leaf order." and the relationship of all classes in a hierarchy must be documented before deserialization. https://www.mongodb.com/docs/drivers/csharp/current/serialization/polymorphic-objects/ (WebFetch).
- Particular docs report an order-dependent GuidSerializer failure ("incorrect order of class mappings"). https://docs.particular.net/persistence/upgrades/mongodb-4to5 (search summary, not opened).
- Did they move away: not found. Class maps remain global static; mitigation is documentation plus the IfNotRegistered API.
### protobuf-net `RuntimeTypeModel.Default`
- Global: yes. README: "The Serializer.* methods are basically just shortcuts to RuntimeTypeModel.Default.*". https://cdn.jsdelivr.net/gh/protobuf-net/protobuf-net@main/README.md (WebFetch).
- Lazy fill, freezing, order-dependent bugs: NOT verified. README is silent; a direct docs fetch (docs.protobuf-net.dev/runtime) was redirected and not followed. No issue found.
### MessagePack-CSharp
- Global mutable default exists: `MessagePackSerializer.DefaultOptions`. README (WebFetch): labelled "Global state; Not recommended when writing library code." Safe if you control all MessagePack code; libraries should pass options explicitly because others "may change this static property." Libraries "must not use StaticCompositeResolver - as it is global state". https://github.com/MessagePack-CSharp/MessagePack-CSharp/blob/master/README.md
- Resolvers are an ordered list (`CompositeResolver`): order matters by construction.
- Whether it moved away from the global: not found.
### System.Text.Json
- `JsonSerializerOptions.Default`: "Gets a read-only, singleton instance"; remarks give a performance reason: shared instance avoids "redundant recomputation of converters". https://learn.microsoft.com/dotnet/api/system.text.json.jsonserializeroptions.default (WebFetch). The page states no rationale about cross-component interference; any such reading is inference. Design-discussion issue in dotnet/runtime: searched, not found. Whether Default was ever mutable: not verified.
- Type-info resolvers: not researched beyond the above.
### Json.NET
- `TypeNameHandling` is a security concern, not an order concern: CA2326 says "An attacker could modify the serialized data to include unexpected types"; mitigation is a custom `ISerializationBinder` restricting types. https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2326 (WebFetch).
- `JsonConvert.DefaultSettings` global behaviour/guidance: the Newtonsoft page fetched says nothing about global effects. Not verified.
### Orleans
- Serializer discovery is build-time source generation (`[GenerateSerializer]`, `[Id]`), not a runtime scan; the docs' older code-generation section lists scan of assemblies referencing the core library plus `KnownType`/`KnownAssembly` attributes. Aliases: "Type aliases are globally scoped, and you cannot have two aliases with the same value in an application." https://learn.microsoft.com/en-us/dotnet/orleans/host/configuration-guide/serialization (saved locally: `C:\Users\Meta\.claude\projects\F--Projects-CultLib\0840c7c7-0e31-410e-9012-afb79c3c0742\tool-results\toolu_0148m7no1AbrJzWmNLicn6N9.txt`, lines 134, 839-841).
- Type manifest / codec registry runtime behaviour, issues: not researched.
### EF Core
- Model is built per `DbContext` type and cached (IMemoryCache; "the built model has a cache size of 100"); initialised at first operation: "just creating a DbContext instance does not cause the EF model to be initialized." https://learn.microsoft.com/en-us/ef/core/performance/advanced-performance-topics
- Assembly scan: `ApplyConfigurationsFromAssembly` — "The order in which the configurations will be applied is undefined, therefore this method should only be used when the order doesn't matter." https://learn.microsoft.com/en-us/ef/core/modeling/ ; and "`EntityTypeConfigurationAttribute` types will not be automatically discovered in an assembly."
- So EF's registry is per context type, not process-global; assembly scan is opt-in and documented as order-undefined.
### Marten
- Storage is a table and upsert function per document type, alias `mt_doc_<alias>`; alias collisions are resolved with `DocumentAlias`. https://martendb.io/schema/storage (WebFetch). Hierarchies share one table via `AddSubClass<T>()`; subclass docs are "stored as the parent type" in the identity map. https://www.martendb.io/documents/hierarchies (WebFetch). Schema is configured in `StoreOptions` per store, not a process-global.
### Java ServiceLoader
- "Service providers are loaded and instantiated lazily, that is, on demand." Providers on the class path load in the order `getResources` finds config files; "The ordering of modules in a module layer is not defined." "Instances of this class are not safe for use by multiple concurrent threads." Caches cleared by `reload`. https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ServiceLoader.html (WebFetch). Visibility is class-loader dependent ("must be visible to the class loader").
## C. Q2. Write/overwrite decisions against an existing record of another type
- RavenDB: REST put docs, request body section: updates must "include its collection name in the metadata" and "A document's collection cannot be modified." Server error code for mismatch: the first fetch summary said HTTP 500; not confirmed on a second fetch. https://docs.ravendb.net/7.1/client-api/rest-api/document-commands/put-documents (WebFetch twice). Ayende, RavenDB 4.0 storage: document key namespace is global, same key cannot exist in two collections (summary). https://ayende.com/blog/173954/the-design-of-ravendb-4-0-physically-segregating-collections The check is against the collection named in the write (derived from the writer's type by the client), not a registry; the "client maps entity type to collection" part is inference from the search summary, not a quoted source. `PutDocumentCommand` page: a missing collection means `@empty`; concurrency via `changeVector`, null "to skip the check". https://docs.ravendb.net/client-api/commands/documents/put
- Marten: per-type tables, so the same id under two types are two rows in two tables; no cross-type check. Same id across subclasses of one hierarchy table: not covered by docs fetched; unverified. Marten docs: upsert is used so users "don't have to check whether a document is new or existing" (search summary of https://www.martendb.io/documents/storing, not opened).
- MongoDB: the server has no type; `_id` unique per collection; the C# driver writes a `_t` discriminator from the written object's class map. Whether the driver checks an existing document's `_t` on replace: not researched; nothing found stating it does.
- Couchbase: key unique within a collection; upsert replaces; no server-side type field enforcement; guidance is one type per collection. https://docs.couchbase.com/server/7.0/learn/data/scopes-and-collections.html (search summary, not opened).
- Orleans: grain identity is type plus key ("type/key"), so same key under two grain classes is two identities, not a conflict. https://learn.microsoft.com/en-ca/%20dotnet/orleans/grains/grain-identity (search summary, not opened). How storage providers key state: not found.
- Result of this pass: no store found in which a process-global type registry decides whether a write may replace an existing record of another type. Found: collection/type fixed by metadata of the writer (RavenDB), type as part of the key (Orleans, Marten tables), or no check (Couchbase upsert). This is a negative search result, not proof of absence. Not searched: Cosmos DB, DynamoDB, EF Core TPH update of an existing row with another discriminator, Realm, LiteDB, Akka.Persistence.
## D. Q3. Guidance on ambient/global registries and determinism
- Mark Seemann (ploeh, 2019): after discussion with co-author van Deursen, "Ambient Context really is an anti-pattern." Reasons given: he has never used it and superior alternatives exist; he says the claim is unproven. https://blog.ploeh.dk/2019/01/21/some-thoughts-on-anti-patterns/ (WebFetch). Examples he cites as found in the BCL (search summary, not verified on page): `Thread.CurrentPrincipal`, `CultureInfo.CurrentCulture`, `HttpContext.Current`. DI book 2nd ed. chapter 5 lists it among DI anti-patterns: https://www.oreilly.com/library/view/dependency-injection-principles/9781617294730/OEBPS/Text/c05.xhtml (not opened).
- MessagePack README (above) is itself a library-author statement on global mutable defaults. MongoDB CSHARP-4424 (above) states the shared-library init-order problem. EF Core docs (above) state assembly-scan order is undefined.
- No .NET guidance specifically about AppDomain-wide/assembly-scan registries and write decisions found.
## E. Not verified / gaps
- protobuf-net lazy registration, freezing and bug history.
- Why System.Text.Json made Default immutable; no maintainer statement found.
- Json.NET DefaultSettings guidance.
- RavenDB exact error text and HTTP status; whether the stored document is unchanged after refusal.
- Marten same-id-across-subclasses behaviour.
- MongoDB driver behaviour on replacing a document whose `_t` differs.
- Several items above rest on WebSearch summaries I did not open (marked "summary").

View file

@ -1,90 +0,0 @@
# Prior art: structural versus name-inclusive schema ids
Serves the store-identity question raised 2026-10-27 in campaign `variants` (operator: member and declared names vary with runtime naming conventions). Eyes pass, session `self-2026-10-27b-eureka`. Facts only; items marked "summary" rest on search summaries not opened.
# Prior art: structural vs name-inclusive schema identity (campaign variants)
Eyes pass, 2026-10-27. Facts and evidence pointers only. "(fetched)" = page opened via WebFetch (the answer is a small-model extraction of the page, not raw text). "(summary)" = WebSearch result summary, page not opened.
## 0. Local facts (read-only)
- `F:\Projects\CultLib\src\GameCult.Caching\CultCache.cs:717-718` schemaId = Sha256(BuildSemanticFingerprint(...)). `:735-761` fingerprint = `schemaName|schemaVersion` then per member ordered by Slot: `slot:MemberName:TypeName:ref|value:TargetSchemaName:one|many`. `contentHash` (`:716`) is a separate Sha256 of canonical JSON (`:763+`).
- `TypeName` = `CultSchemaTypeNames.FromType`: CLR `Type.FullName` (namespace-qualified), generics as `Name<args>`, arrays `T[]` (`src\GameCult.Caching\CultSchemaTypeNames.cs:9-31`). So the fingerprint contains .NET type names as well as member names.
- Contract `Contracts\cultcache-persistence-format.md:257-284` lists what canonicalization must preserve: slots, types, required/default, nullability, collection semantics, enum constraints, encoding shape. Member names and declared schema name are not in that list. `:171-179` says the id is not derived from C#/Rust/TS type names and the same shape should give the same id "regardless of origin language". `:180-190` defines schemaId = "same semantic persistence shape?" vs contentHash = "exact same canonical schema document?". Example catalog entry (`:140-170`) carries optional `supersedesSchemaIds`, `migrationFamily`; its example canonicalSchema fields do carry `"name": "Name"` (PascalCase). Soft-migration reader flow `:286-345` (exact_match / soft_migrate / reject).
- Runtimes do not derive ids; they emulate C# names:
- Python: `packages\cultcache-py\src\cultcache_py\documents.py:326` `member_name=_pascal_case(field.name)`; `:516-517` splits on `_` and capitalises. `schema_id` defaults to `self.type` (`:218`); `compatible_schema_ids` field (`:188,242`).
- TypeScript: `packages\cultcache-ts\src\codec.ts:226-232` `pascalCase` (splits on non-alphanumerics), `:267` `memberName: pascalCase(field.name)`; throws if a codec has no C# type name (`:~262`).
- Rust: `packages\cultcache-rs\src\lib.rs:295,308` `serde(rename_all = "camelCase")` on envelope structs; `schema_id: Some(T::TYPE.to_string())` at `:2856`. I did not trace how Rust document types obtain a sha256 id.
- Python test pins a literal id from shared vectors: `packages\cultcache-py\tests\test_cultcache.py:808` (`tests\vectors\document-variants-c0`).
- C# also has `CompatibleSchemaIds` (`CultCache.cs:28,313,570`): a record whose persisted id is in the list is accepted (an alias-list mechanism; I saw only the grep lines, not the logic).
- Ruling `variants:ruling:wire-id-is-version` (eureka-state, InForce, ruled 2026-09-30, operator quote "Agreed on schema identity"): wire schema id = declared version string `<name>.vN`; every runtime stamps/resolves it exactly; a wire binding without `.v<digits>` is refused; "Store ids are unchanged." Cited source: document-variants-cut.md:552-555 (not opened).
## 1. How established formats define identity/compatibility, and whether names participate
Protocol Buffers (fetched https://protobuf.dev/programming-guides/proto3/ and /json/)
- Field number identifies the field in the binary wire; changing it = delete + add. Field names appear only in TextProto/JSON encodings; reserve deleted numbers and, for JSON/text, names. The proto3 page has no explicit safe/unsafe entry for renaming; names matter only for JSON/text.
- JSON mapping: names become lowerCamelCase unless `json_name` is set; "Parsers accept both the lowerCamelCase name" and the original proto name (/programming-guides/json/).
- Message type identity in `Any` is by name: default type URL `type.googleapis.com/<package>.<message>`, "a globally unique identifier" (proto3 page, #any). Type identity is nominal; compatibility is by field numbers.
Apache Avro (fetched https://avro.apache.org/docs/1.11.1/specification/)
- Parsing Canonical Form (PCF): STRIP keeps only attributes relevant to parsing (type, name, fields, symbols, items, values, size); doc and aliases dropped; FULLNAMES replaces short names with fullnames and drops namespace attributes. Textually equal PCF means "the same" for any reader. Names ARE in the canonical form (record and field names), as is field declaration order. Defaults are absent from the kept list (extractor's inference; spec text not confirmed).
- Fingerprints: CRC-64-AVRO in single-object encoding (marker C3 01 + 8-byte LE fingerprint); spec recommends at least a 64-bit Rabin, with SHA-256 / MD5 options for larger sets.
- Name rules: `[A-Za-z_][A-Za-z0-9_]*`, dot-separated namespaces, equality on case-sensitive fullname.
- Resolution: records match on same unqualified name; "Named types and fields may have aliases"; implementations "may optionally" use aliases to map writer to reader. So in Avro names participate in identity (fingerprint) and resolution; aliases are the rename mechanism and are excluded from the fingerprint.
Cap'n Proto (fetched https://capnproto.org/language.html)
- Files and types carry 64-bit IDs; `capnp id` generates them randomly. Default ID for a declaration without one = first 8 bytes of MD5 of (parent scope ID, declaration name). "You cannot change the name of a type that doesn't have an explicit ID"; symbolic names can change if ID/ordinals stay. Stated rationale for IDs: short, unambiguous, full names waste wire space. The page does not say why structural hashes were rejected; I found no Varda statement (search pointed at a mailing-list thread "Schema Refactoring and Unique IDs", Aug 2023, unopened).
- Encoding page (https://capnproto.org/encoding.html, fetched): adding fields is backward compatible because field positions depend only on lower-numbered fields; nothing on type identity.
FlatBuffers (fetched https://flatbuffers.dev/evolution/ and /schema/)
- Field positions by declaration order or explicit `id: n` (contiguous from 0, on all fields if used). Renaming "generally OK" because names are not in the binary. `file_identifier` is exactly 4 chars at bytes 4-7; docs frame it as a file-level check and recommend unions for distinguishing messages.
Thrift (fetched https://raw.githubusercontent.com/apache/thrift/master/doc/specs/thrift-binary-protocol.md)
- Binary protocol: field header = type + field id; "the field name is not encoded". (IDL page gave nothing on omitted ids.)
MessagePack-CSharp (fetched https://github.com/MessagePack-CSharp/MessagePack-CSharp, first 100k of 105k chars)
- `[Key(int)]` on all members => array encoding; string keys => map. Keys should be sequential from 0 (gaps cost nulls); do not reuse indexes of removed members; keep obsolete members until clients update. Union keys are ints, unique, serialized as [key, object]. No type identity in the payload.
Microsoft Bond (fetched https://microsoft.github.io/bond/manual/bond_cpp.html)
- Changing field ordinals breaks wire compat; no explicit versioning; Simple JSON matches by field name so a rename breaks it. No schema hash/GUID documented there. Bebop: wiki page empty (points to docs.bebop.sh, not opened).
Unison (fetched https://www.unison-lang.org/docs/the-big-idea/ and /docs/language-reference/unique-types; hash size from search summary)
- Definitions identified by hash of syntax tree; names are "separately stored metadata"; hash excludes names (512-bit SHA3 per search summary, unopened).
- Structural types: "Unison does not uniquely identify a type by its name"; same-shape types are the same type (Suit/Direction, four nullary constructors, interchangeable). `unique` types: compiler generates a UUID that feeds the hash, so identity becomes nominal. Which is the default keyword was not confirmed.
IPLD (fetched https://ipld.io/docs/schemas/)
- Matching is structural: "the names of types aren't expected to be found in the serialized data"; match is by structural outline. The page does not say schemas are themselves content-addressed. Versioning is left to developers; "Migrations" subpage not opened.
## 2. Failure modes of purely structural identity
- Unison documents it directly: structurally identical enums are one type unless declared `unique` (unison-lang.org/docs/language-reference/unique-types, fetched). Its mitigation is a per-declaration UUID mixed into the hash (identity re-nominalised).
- Language literature (summary, pages not opened): structural typing is prone to accidental conformance/type equivalence; nominal typing prevents it at the cost of rigidity (Wikipedia "Nominal type system"; arXiv 1007.2123 lecture notes; Erlang nominals doc https://www.erlang.org/doc/system/nominals.html says nominal types exist to prevent misuse of same-structure types). TypeScript "brand" = phantom discriminator field. No serialization-specific source found.
- Formats that avoid the collision by carrying a name or tag outside the shape: protobuf Any type URL (full name), Avro fullname in PCF, Cap'n Proto explicit IDs, Unison `unique`, MessagePack-CSharp union key ints, FlatBuffers file_identifier/unions.
- Not found: a documented incident of two same-shape serialized types colliding under a structural id (one search, nothing).
## 3. Failure modes of name-inclusive identity across languages
- Protobuf: default camelCase conversion is "always valid but not necessarily unique"; proto3 compiler rejects JSON-name conflicts; serialized duplicate keys parse differently by runtime (Edition Zero design doc, summary: https://flutter.googlesource.com/third_party/protobuf/+/HEAD/docs/design/editions/edition-zero-json-handling.md). Snake_case vs camelCase JSON mismatches in practice: https://github.com/fullstorydev/grpcurl/issues/105 (summary); https://github.com/open-telemetry/opentelemetry-proto/issues/476 (summary: spec contradicted itself on camelCase vs original names). Protobuf's mitigation: parsers accept both forms.
- Serde (fetched https://serde.rs/container-attrs.html): `rename_all` accepts lowercase, UPPERCASE, PascalCase, camelCase, snake_case; per-field `rename`.
- Avro restricts names to `[A-Za-z_][A-Za-z0-9_]*` with case-sensitive fullname equality, so snake_case and PascalCase spellings are distinct names; renames go through aliases, which PCF drops.
- Cap'n Proto: auto-derived IDs depend on the declaration name, so a rename without an explicit ID changes the ID.
- Bond Simple JSON: rename breaks compatibility (name matching).
- Local: Python/TS already convert to PascalCase to reproduce C#'s fingerprint (section 0); the fingerprint's TypeName is the CLR full name (`CultSchemaTypeNames.cs:30`) and TS throws when a codec lacks a C# type name.
- Registry normalisation (summary): Confluent Schema Registry `normalize.schemas=true` maps semantically-equal schemas to one id (https://www.confluent.io/blog/best-practices-for-confluent-schema-registry/); sources disagree on whether JSON Schema property order is normalised.
## 4. Migration precedents for changing identity derivation
- Git SHA-1 -> SHA-256 (fetched https://git-scm.com/docs/hash-function-transition): per-repo format marker (`extensions.objectFormat`, `compatObjectFormat`, `repositoryFormatVersion=1` makes old git refuse); bidirectional SHA-256<->SHA-1 mapping (pack idx v3, `loose-object-idx`); four transition modes (dark launch, early, late, post); conversion at the network boundary. It is a design doc, not a statement of what has shipped.
- IPFS CIDv0 -> CIDv1 (fetched https://docs.ipfs.tech/concepts/content-addressing/): v0 (46 chars, "Qm") and v1 (self-describing version/codec/multihash) coexist; `ipfs cid format -v 1` converts; v1->v0 only for dag-pb.
- Docker manifest schema 1 -> 2 (summary, mirror pages unopened): digests differ per manifest format; pull-by-digest gets no translation, pull-by-tag may be translated; repush converts. https://pulp.plan.io/issues/2099.
- Warpcast API hash v1 -> v2 (summary, https://warpcast.notion.site/Warpcast-API-Hash-Migration-Public-ccdd6de8ac604783b9596cfd623ddbac): two-week dual-hash period, then only v2.
- Cap'n Proto explicit-ID escape hatch for renamed/moved declarations; Avro aliases as the rename bridge in resolution; MessagePack-CSharp keep-obsolete-members (all fetched, above).
- Local: contract `supersedesSchemaIds` / `migrationFamily` (`cultcache-persistence-format.md:~140-170`) and C#/Py `CompatibleSchemaIds` exist as alias lists.
- Not found: Unison changing its hash derivation (one search, no docs); any serialization store that switched from name-inclusive to structural ids.
## Could not verify / not done
- Cap'n Proto designer's rationale against structural hashing (not in docs; mailing list unopened).
- Avro: whether PCF drops defaults (spec text) and any JIRA on it; spec page was summarised by the fetch model.
- Bebop, Thrift IDL id-omission rule, Unison default struct/unique keyword, IPLD Migrations page, Edition Zero doc (summary only), Confluent blog (summary only), nominal/structural papers (titles only).
- Rust runtime's sha256 derivation path; `document-variants-cut.md:552-555` not opened; `CompatibleSchemaIds` logic not read.
- eureka-state worked (whoami ok); only the one ruling was queried.

View file

@ -1,28 +0,0 @@
# Prior art: presence provider as a library, launcher as a consumer
Served: `cultlib-gaps:cut_spec:cut-cultnet-rs-presence-provider-a.r1` and the
Defaulted rulings `presence-provider-network-free-core`,
`presence-credentials-both-idunn-drivers` and `presence-provider-consumers-adopt`,
applying the operator's `gamecult-chat:ruling:presence-one-owner-library-launcher-consumer`.
Imagination pass, 2026-10-29, from recall of the cited manuals (not re-fetched).
- libsystemd `sd_notify(3)`: a service links a small library and reports its
own state (READY, STATUS, WATCHDOG); programs that cannot link it use
`systemd-notify(1)` or a wrapper. Same split as cultnet-rs provider plus
idunn-launch as the sidecar consumer.
- `sd_listen_fds_with_names(3)`: checks LISTEN_PID against the process, sets
close-on-exec on passed descriptors, finds them by name, and can unset the
LISTEN_* environment. This set is the descriptor rule the provider adopts.
- systemd watchdog (`WATCHDOG=1` within `WatchdogSec`): the model for the
warming heartbeat.
- Consul: TTL checks updated by the service through the client API, versus
script/HTTP checks the agent runs for services that cannot. In-process
versus sidecar again.
- Kubernetes: kubelet-run probes observe from outside; readiness gates are
reported by the app. Idunn's route challenge is the outside observer, the
signed presence the app's own report.
Existing copies compared: CodexConnector `src/idunn_health.rs` (two
descriptors, strict PID check), Idunn launcher (two descriptors, no PID check),
cultnet-ts `idunn-runtime-authority.ts` (descriptors by name, systemd private
PID case). The provider takes the strictest union.

View file

@ -15,9 +15,6 @@ Cross-runtime parity has three layers:
without translation folklore.
Every store reader refuses, by name, a header or record slot it does not
understand (vectors: `tests/vectors/document-variants-c0`).
Element ids: a store that can hold them declares `cultcache.store.v3`; TypeScript,
Python and Rust read it and keep the header, and the C# runtime decides the mark from content, and these runtimes never drop it. A C# reader from `cultlib-unity-v2.0.0` on refuses a v3 single-file store it cannot read (earlier pins do not)
(vectors: `tests/vectors/document-variants-c2a`).
Document variants: C# resolves them (`cultcache.store.v2`); every other runtime,
CultMesh single-file reads, the directory store and CultNet refuse a variant by name
(vector written by C#: `tests/vectors/document-variants-c1`).
@ -44,8 +41,8 @@ to speak CultNet or participate in CultMesh.
| 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 hydration of raw snapshots into the receiver's own registered type for each schema id, 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 into the receiver's own registered type for the schema, 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 |
| 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

View file

@ -26,7 +26,7 @@ caught before it ships instead of written up afterward.
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml`, `workflow_dispatch` only; npm publishing is off (see "Coverage gaps") |
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` |
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
@ -198,8 +198,8 @@ or a missing declaration refuses), and:
proof: with no tag of its own prefix it is a first release. The suite test
that pins every declared prefix guards it: each prefix is declared once, each
workflow tag trigger must be a declared prefix, and each such package's prefix
must be a workflow trigger or, for a package the workflow does not tag-trigger
(the Caching Unity package, `cultcache-ts`), the prefix of tags that hold its own manifest at the tag's own
must be a workflow trigger or, for the Caching Unity package the workflow does
not build, the prefix of tags that hold its own manifest at the tag's own
version, so a typo or a copied prefix fails the suite. Nothing releases
without the suite passing; see below for how each release path runs it.
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
@ -352,40 +352,10 @@ own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal
at unchanged source and CRLF checkout means this SDK or host differs from the
one that built the tracked DLL.
### Before pushing a tag
A tag run uses the workflow files at the tagged commit, not the ones on main, and
the forge's GitHub mirror forwards the tag, so both CI systems run it. Before
pushing any tag, resolve the commit it will point at and read the tag-triggering
workflows as they are at that commit:
```
sha=$(git rev-parse <commit-the-tag-will-point-at>)
git ls-tree -r --name-only "$sha" .github/workflows
git show "$sha":.github/workflows/publish-packages.yml | sed -n '/^on:/,/^jobs:/p'
```
Confirm that the tag's prefix is a trigger there if you mean it to publish, and
is not if you do not. Do the same for every other workflow listed that has a
`tags:` trigger. A guard merged after that commit does not protect a tag that
points before it.
`cultcache-ts-v0.15.0` was pushed on `7d292085`, which predates the guard, and
its push started a publish run on both systems (GitHub run 42392334869, forge run
454). The forge job log shows how it ended: the Publish step ran
`release-after-suite.mjs`, whose `npm test` failed (11 cross-runtime tests,
`cargo: not found` on the runner), so it printed "@gamecult/cultcache-ts's own
tests failed; not releasing." and never reached `npm publish`. Neither
`cultcache-ts` nor `@gamecult/cultcache-ts` exists on the npm registry
(both return 404, checked 2026-11-08). The GitHub run's own log needs a login
to read, so its failure cause is inferred from the identical workflow and sha.
### Coverage gaps
`publish-packages.yml` only triggers on `cultcache-py-v*`, `cultnet-py-v*`, and
`cultmesh-py-v*` tag pushes (`cultcache-ts-v*` is deliberately not a trigger: npm
publishing is off until ruled on, and the cultcache-ts job runs from
`workflow_dispatch` only, publishing nothing) — it does not build or gate
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`,
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate
the Unity UPM packages at all; those are consumed directly by git tag
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags

View file

@ -1,572 +0,0 @@
# Start Here
CultLib is one stack in several languages: **CultCache** (typed `.cc` persistence),
**CultNet** (schema-v0 wire messages and transports) on top of it, and **CultMesh** (Verse
discovery, replication, authority) on top of that. **CultMath** and **Geometry** sit beside the
stack as portable numerics and engine-neutral geometry. The C# runtime in `src/` is the
reference; the other runtimes keep wire parity with it.
If you want typed state on disk, start with CultCache. Everything else assumes it.
## Language x package
A cell links a snippet that a test in this repo runs, or says **not yet**. "Package" links
the package's own README or source, which is real but carries no tested first-call snippet from
this page.
| | CultCache | CultMath | Geometry | CultNet | CultMesh |
| --- | --- | --- | --- | --- | --- |
| **C#** | [first call, tested](#c-first-call); [update, tested](#c-update-with-retry) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` |
| **TypeScript** | [first call, tested](#typescript-first-call); [update, tested](#typescript-update-with-retry) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run |
| **Rust** | [first call, tested](#rust-first-call); [update, tested](#rust-update-with-retry) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet |
| **Python** | [first call, tested](#python-first-call); [update, tested](#python-update-with-retry) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet |
For the whole
picture of what each piece is for, see the
[package chooser in the top-level README](../../README.md#which-package-do-i-want).
## C# first call
Define a typed document, put it in a cache backed by a `.cc` file, read it back through a second
cache. The source is
[`tests/GameCult.Caching.Tests/FirstCallTests.cs`](../../tests/GameCult.Caching.Tests/FirstCallTests.cs),
which is what `dotnet test tests/GameCult.Caching.Tests --filter FirstCallTests` runs, so the snippet
cannot drift without the test failing.
```csharp
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
[CultDocument("gamecult.first_call_item", "gamecult.first_call_item.v1")]
[MessagePackObject]
public sealed class Item
{
[Key(0)] [CultName] public string Name = string.Empty;
[Key(1)] public string Category = string.Empty;
[Key(2)] public int Value;
}
public static class FirstCall
{
public static async Task<Item?> Run(string path)
{
var store = new SingleFileMessagePackBackingStore(path);
var cache = new CultCache();
cache.AddBackingStore(store);
var handle = await cache.AddAsync(new Item { Name = "Potion", Category = "Consumable", Value = 50 });
store.PushAll(); // write the .cc file
var reopened = new CultCache();
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await reopened.PullAllBackingStoresAsync(); // read it back
var potion = reopened.Get<Item>(handle.Key); // by key
var byName = reopened.GetByName<Item>("Potion"); // by [CultName]
return byName ?? potion;
}
}
```
`FirstCall.Run` is the call; a second test in that file fails if the block above stops matching it. More: `src/GameCult.Caching/README.md`.
## C# update with retry
Change a record you may not be the only one changing. `Update` reads the record as its store holds it, hands your function a fresh
copy of it, and commits the result only if the record is still the one it read. If another writer got there first, it
reads again and runs your function again, up to five times. The source is
[`tests/GameCult.Caching.Tests/UpdateCallTests.cs`](../../tests/GameCult.Caching.Tests/UpdateCallTests.cs); the
snippet reuses `Item` from the first call.
```csharp
using GameCult.Caching;
using GameCult.Caching.MessagePack;
public static class UpdateCall
{
public static CultUpdateResult<Item> Restock(string path, CultRecordKey key)
{
var cache = CultCacheMessagePack.Create(path);
return cache.Update<Item>(key, item =>
{
item ??= new Item { Name = "Potion", Category = "Consumable" };
item.Value += 10;
return item;
});
}
}
```
The write is durable when `Update` returns, with no `PushAll`. A lost race re-runs the function, so keep it free of
side effects; when the attempts run out the result says `LostRace` instead of throwing. `UpsertAsync` plus
`PushAll` is the blind opt-out: last writer wins.
## TypeScript first call
Same shape in TypeScript: the document is a name, a schema id and a `parse`-able schema (zod here),
and the `.cc` file is wire-compatible with the C# one. The source is
[`packages/cultcache-ts/test/first-call.test.ts`](../../packages/cultcache-ts/test/first-call.test.ts),
run by `npm test` in `packages/cultcache-ts` (or `node --test dist-test/test/first-call.test.js`
after `tsc`).
```ts
import { z } from "zod";
import {
CultCache,
SingleFileMessagePackBackingStore,
defineDocumentRegistry,
defineDocumentType,
} from "@gamecult/cultcache-ts";
const itemDocument = defineDocumentType({
type: "item",
schemaId: "gamecult.first_call_item.v1",
schemaName: "gamecult.first_call_item",
schemaVersion: "gamecult.first_call_item.v1",
schema: z.object({ name: z.string(), category: z.string(), value: z.number().int() }),
name: "name",
});
const registry = defineDocumentRegistry(itemDocument);
async function firstCall(path: string) {
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(path))
.build();
await cache.pullAllBackingStores();
await cache.put(itemDocument, "item:potion", { name: "Potion", category: "Consumable", value: 50 });
const reopened = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(path))
.build();
await reopened.pullAllBackingStores();
const potion = reopened.get(itemDocument, "item:potion");
const byName = reopened.getByName(itemDocument, "Potion");
return { potion, byName };
}
```
The test imports from `../src/index` where the block reads `@gamecult/cultcache-ts`; a second test in that file fails if the block stops matching the rest. More:
`packages/cultcache-ts/README.md`.
## TypeScript update with retry
`update` changes a record from what the store holds now and commits only if it is still that record. The source is the
second region of
[`packages/cultcache-ts/test/first-call.test.ts`](../../packages/cultcache-ts/test/first-call.test.ts), which uses the
definitions of the first call above; a test in that file fails if the block stops matching the file.
```ts
async function restock(path: string) {
const cache = CultCache.builder()
.withRegistry(registry)
.withGenericStore(new SingleFileMessagePackBackingStore(path))
.build();
await cache.pullAllBackingStores();
return cache.update(itemDocument, "item:potion", (item) => {
const current = item ?? { name: "Potion", category: "Consumable", value: 0 };
return { ...current, value: current.value + 10 };
});
}
```
The write is durable when the promise resolves. A lost race re-runs the function, so keep it free of side effects; when the
attempts run out the result says `lostRace` instead of throwing. `put` is the blind opt-out: last writer wins.
## Rust first call
Same shape with `derive(DatabaseEntry)`: slots are `#[cultcache(key = N)]`, so the `.cc` payload is the
slot-indexed array the C# runtime writes. The source is
[`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), run by
`cargo test --locked --test first_call` in `packages/cultcache-rs`; a second test in that file fails if the block
below stops matching the file. The Rust runtime has no name lookup, so the read-back is by key. More:
`packages/cultcache-rs/README.md`.
```rust
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "item")]
struct Item {
#[cultcache(key = 0)]
name: String,
#[cultcache(key = 1)]
category: String,
#[cultcache(key = 2, default)]
value: u32,
}
fn first_call() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("first-call.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Item>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
cache.pull_all_backing_stores()?;
cache.put("item:potion", &Item {
name: "Potion".to_string(),
category: "Consumable".to_string(),
value: 50,
})?; // written to the .cc file before this returns
let mut reopened = CultCache::new();
reopened.register_entry_type::<Item>()?;
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
reopened.pull_all_backing_stores()?; // read it back
let potion = reopened.get_required::<Item>("item:potion")?;
assert_eq!(potion.name, "Potion");
assert_eq!(potion.value, 50);
Ok(())
}
```
## Rust update with retry
`update` changes a record from what the store holds now and commits only if it is still that record. The source is the
second region of
[`packages/cultcache-rs/tests/first_call.rs`](../../packages/cultcache-rs/tests/first_call.rs), which uses the `Item` of
the first call above; a test in that file fails if the block stops matching the file.
```rust
fn restock(path: &Path) -> anyhow::Result<CultUpdateResult<Item>> {
let mut cache = CultCache::new();
cache.register_entry_type::<Item>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
cache.pull_all_backing_stores()?;
cache.update::<Item, _>("item:potion", |item| {
// The store's copy of the record, or nothing if it has none; this may run again on a lost race.
let mut item = item.unwrap_or(Item {
name: "Potion".to_string(),
category: "Consumable".to_string(),
value: 0,
});
item.value += 10;
Some(item)
})
}
```
The write is durable when `update` returns. A lost race re-runs the closure, so keep it free of side effects; when the
attempts run out (five, or the bound `update_with_attempts` takes) the outcome says `LostRace` instead of failing, and
returning `None` writes nothing and says `Declined`. `put` is the blind opt-out: last writer wins.
## Python first call
A document is a definition with an encoder and decoder; `name=` registers the name lookup, `indexes=` unique lookups and
`roles=` many-row selection aliases. The source is
[`packages/cultcache-py/tests/test_first_call.py`](../../packages/cultcache-py/tests/test_first_call.py),
run by `python -m unittest tests.test_first_call` in `packages/cultcache-py` with `src` on `PYTHONPATH`
and `msgpack` installed (the store needs it). A second test fails if the block below stops matching the
file. More: `packages/cultcache-py/README.md`.
```python
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from cultcache_py import CultCache, SingleFileMessagePackBackingStore, define_document_type
@dataclass
class Item:
name: str
category: str
value: int
item_doc = define_document_type(
"item",
encode=lambda item: asdict(item),
decode=lambda raw: Item(**raw),
name="name",
)
def first_call() -> Item:
path = Path(tempfile.mkdtemp()) / "first-call.cc"
cache = (
CultCache.builder()
.register_document_type(item_doc)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
cache.pull_all_backing_stores()
cache.put(item_doc, "item:potion", Item("Potion", "Consumable", 50)) # written to the .cc file
reopened = (
CultCache.builder()
.register_document_type(item_doc)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
reopened.pull_all_backing_stores() # read it back
assert reopened.get_by_name(item_doc, "Potion") == reopened.get_required(item_doc, "item:potion")
return reopened.get_required(item_doc, "item:potion")
```
## Python update with retry
`update` changes a record from what the store holds now and commits only if it is still that record. The source is the
`update` region of
[`packages/cultcache-py/tests/test_first_call.py`](../../packages/cultcache-py/tests/test_first_call.py), which uses the
definitions of the first call above; a test in that file fails if the block stops matching the file.
```python
def restock(path: Path):
cache = (
CultCache.builder()
.register_document_type(item_doc)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
cache.pull_all_backing_stores()
return cache.update(item_doc, "item:potion", lambda item: Item("Potion", "Consumable", (item.value if item else 0) + 10))
```
The write is durable when `update` returns. A lost race re-runs the function, so keep it free of side effects; when the
attempts run out the result's `outcome` is `UpdateOutcome.LOST_RACE` instead of a raise. `put` is the blind opt-out: last
writer wins.
## CultNet first calls
CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same
shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack
payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each
block is verbatim from the test that runs it; a second test in the same file fails if the block stops
matching.
### C# CultNet first call
Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs),
run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`.
```csharp
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Networking;
using MessagePack;
[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")]
[MessagePackObject]
public sealed class Note
{
[Key(0)] [CultName] public string Title = string.Empty;
[Key(1)] public string Body = string.Empty;
}
public static class CultNetFirstCall
{
public static async Task<Note?> Run()
{
var inbox = new CultCache(); // the receiver's cache
var documents = new CultNetDocumentRegistry();
var received = new TaskCompletionSource<bool>();
// Receive: a loopback listener that applies each raw document put to the inbox.
using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0));
server.OnCultNet<CultNetDocumentPutRawMessage>(async (put, peer) =>
{
await documents.ApplyRawDocumentPutMessageAsync<Note>(inbox, put);
received.TrySetResult(true);
});
// Send: a typed document as a cultnet.document_put_raw.v0 message.
using var client = new TcpFramedCultNetSchemaClient();
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
var handle = new CultRecordHandle<Note>(new CultRecordKey("note:hello"));
client.SendCultNet(documents.CreateRawDocumentPutMessage(
"put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" }));
await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
return inbox.Get<Note>(handle.Key);
}
}
```
### TypeScript CultNet first call
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`.
```ts
import { createServer, connect } from "node:net";
import { z } from "zod";
import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts";
import {
CultNetDocumentRegistry,
createTcpFramedCultNetPeer,
defineCultNetDocumentBinding,
} from "@gamecult/cultnet-ts";
const noteDocument = defineDocumentType({
type: "note",
schemaId: "gamecult.first_call_note.v1",
schemaName: "gamecult.first_call_note",
schemaVersion: "gamecult.first_call_note.v1",
schema: z.object({ title: z.string(), body: z.string() }),
name: "title",
});
const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]);
async function firstCall() {
const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const;
const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache
// Receiver: a loopback listener whose peer applies each document put to the inbox.
const received = new Promise<void>((resolve, reject) => {
const server = createServer((socket) => {
const peer = createTcpFramedCultNetPeer(socket, options);
peer.on("invalidMessage", reject);
peer.on("message", async (message) => {
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return;
await documents.applyRawDocumentPutMessage(inbox, message);
peer.close();
server.close();
resolve();
});
}).listen(0, "127.0.0.1", () => {
// Sender: connect, build a typed put message, send it.
const { port } = server.address() as { port: number };
const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options);
sender.send(
documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", {
title: "Hello",
body: "sent over CultNet",
}),
);
received.finally(() => sender.close());
});
});
await received;
return inbox.get(noteDocument, "note:hello");
}
```
### Rust CultNet first call
Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by
`cargo test --locked --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`.
```rust
use std::net::{TcpListener, TcpStream};
use std::thread;
use cultnet_rs::{
CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract,
TcpFramedTransportConnection, create_tcp_framed_transport_profile,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "note")]
struct Note {
#[cultcache(key = 0)]
title: String,
#[cultcache(key = 1)]
body: String,
}
fn first_call() -> anyhow::Result<Note> {
let mut documents = CultNetDocumentRegistry::new();
documents.register(CultNetDocumentBinding::for_entry_with_schema_id::<Note>(
"gamecult.first_call_note.v1".to_string(),
"gamecult.first_call_note.v1".to_string(),
))?;
let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default());
// Receiver: a loopback listener that applies the document put it receives to its own cache.
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
let receiver = thread::spawn({
let (documents, profile) = (documents.clone(), profile("receiver"));
move || -> anyhow::Result<Note> {
let (stream, _) = listener.accept()?;
let frame = TcpFramedTransportConnection::new(stream, profile).receive()?;
let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?;
let mut inbox = CultCache::new();
inbox.register_entry_type::<Note>()?;
documents.apply_raw_document_put_message::<Note>(&mut inbox, &message)
}
});
// Sender: build a typed put message, encode it for the wire, send one frame.
let put = documents.create_raw_document_put_message(
"put-1",
"note:hello",
&Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() },
CultNetDocumentPutOptions::default(),
)?;
let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?;
TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender"))
.send("schema", &bytes)?;
receiver.join().expect("receiver thread")
}
```
### Python CultNet first call
Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by
`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on
`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`.
```python
import socket
from dataclasses import asdict, dataclass
from cultcache_py import CultCache, define_document_type
from cultnet_py import (
apply_raw_document_record,
document_put_raw,
encode_frame,
parse_message,
read_frame,
schema_document_map,
)
@dataclass
class Note:
title: str
body: str
note_doc = define_document_type(
"note",
encode=lambda note: asdict(note),
decode=lambda raw: Note(**raw),
name="title",
)
def first_call() -> Note:
sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP
# Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame.
put = document_put_raw(
message_id="put-1",
key="note:hello",
schema_id=note_doc.catalog_entry().schema_id,
stored_at="2026-01-01T00:00:00Z",
payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")),
)
sender.sendall(encode_frame(put.to_bytes()))
# Receive: read the frame, parse the message, apply its record to a second cache.
message = parse_message(read_frame(receiver.makefile("rb")))
assert message.schema_version == "cultnet.document_put_raw.v0"
inbox = CultCache.builder().register_document_type(note_doc).build()
apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"])
sender.close()
receiver.close()
return inbox.get_required(note_doc, "note:hello")
```
## Where next
- Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`.
- Application path, four chapters: `src/GameCult.Mesh/docs/getting-started/README.md`.

View file

@ -1,196 +0,0 @@
# Recipe: a two-client .io game over CultCache + CultMesh (TypeScript)
A tiny arena. One process is the authority: it holds a single `io.arena_state` document in a CultCache
and serves it over CultMesh. Each client discovers the authority through the Odin catalog, leases the
document, and invokes `join`, `move` and `leave` operations. Two clients see each other join, move and
leave because both lease the same document.
## What ran, what did not
| Piece | Status |
| --- | --- |
| Two clients, real WebSockets, real `@gamecult/cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) |
| Edge clamp, and the source below matching the files that ran | **tested** |
| Browser bundle builds and the page is served | **tested** (HTTP fetch only) |
| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe |
| Run by CI / `npm run test:ts` | **not yet**: the recipe has its own `package.json` (for `ws`) outside the workspaces; run it by hand as below |
| Authority state on disk (`.cc` file) | **not yet**: the arena cache is in memory |
| Remote play: Odin roots and a signed route | **not yet**: the client uses `local-development` trust, loopback only |
| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority |
| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported |
No `@gamecult/cultmesh-ts` or `@gamecult/cultnet-ts` TypeScript authority server exists. The server below is the CultNet
message handling a provider owes, written in this recipe with the `ws` package.
## Run it
```
npm ci --ignore-scripts # repo root
(cd packages/cultnet-ts && npm run build) # then cultcache-ts, cultmesh-browser, same command
cd docs/start/io-game && npm install
npm test # node --test arena.test.ts, Node 24
node serve.ts # http://127.0.0.1:8080, open it twice
```
Tested on Node v24.14, which runs `.ts` files directly (no type check; `tsc` is not part of this recipe).
## schema.ts
```ts
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
export interface Player { x: number; y: number }
export interface ArenaState { players: Record<string, Player> }
// The one shared document: every player's position. The server owns it; clients lease it.
export const arenaDocument = defineDocumentType({
type: "io.arena_state",
schemaId: "io.arena_state.v1",
schema: { parse: (value: unknown) => value as ArenaState },
});
export const arenaKey = "arena:main";
export const verseId = "io.arena";
export const authorityRuntimeId = "io.arena-daemon";
export const arenaSize = 100;
```
## server.ts
```ts
import { createServer, type RequestListener, type Server } from "node:http";
import { CultCache } from "@gamecult/cultcache-ts";
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
import { WebSocketServer, type WebSocket } from "ws";
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
await cache.put(arenaDocument, arenaKey, { players: {} });
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
await new Promise<void>(resolve => http.listen(port, host, resolve));
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
const subscribers = new Map<WebSocket, string[]>();
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
const record = () => ({
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
payloadEncoding: "messagepack" as const,
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
sourceRuntimeId: authorityRuntimeId,
});
const publish = async (next: ArenaState) => {
await cache.put(arenaDocument, arenaKey, next);
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
}
};
let nextId = 0;
const operations: Record<string, (p: any) => Promise<object>> = {
join: async () => {
const id = `p${++nextId}`;
const state = cache.getRequired(arenaDocument, arenaKey);
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
return { id };
},
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
const state = cache.getRequired(arenaDocument, arenaKey);
const me = state.players[id];
if (!me) return { ok: false };
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
return { ok: true };
},
leave: async ({ id }: { id: string }) => {
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
await publish({ players: rest });
return { ok: true };
},
};
const wss = new WebSocketServer({ server: http });
wss.on("connection", (ws, req) => {
const odin = req.url === "/odin";
ws.on("close", () => subscribers.delete(ws));
ws.on("message", async raw => {
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
send(ws, {
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
verses: [{
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
}],
});
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
const handler = operations[m.operation];
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
send(ws, {
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
});
}
});
});
return {
odinUrl: `${endpoint}/odin`,
state: () => cache.getRequired(arenaDocument, arenaKey),
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
};
}
```
## client.ts
```ts
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
const mesh = await CultMeshBrowserClient.connect({
verseId, authorityRuntimeId, runtimeId, rendezvous,
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
});
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
const call = async (operation: string, payload: object) =>
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
const { id } = await call("join", {});
lease.watch(record => {
if (!record) return;
latest = decodeCultNetPayload(record, arenaDocument);
onState(latest, id!);
});
return {
id: id!,
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
state: () => latest,
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
};
}
```
`browser.ts`, `index.html` and `serve.ts` in [`io-game/`](io-game/) draw the canvas and bundle the client with esbuild.

View file

@ -1,73 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { joinArena } from "./client.ts";
import { startArena } from "./server.ts";
import { arenaSize, type ArenaState } from "./schema.ts";
const until = async (cond: () => boolean) => {
for (let i = 0; i < 200 && !cond(); i++) await new Promise(r => setTimeout(r, 10));
assert.ok(cond(), "condition not reached in 2s");
};
test("two clients join one arena, see each other, and see each other move", async () => {
const arena = await startArena();
const seenByA: ArenaState[] = [];
const seenByB: ArenaState[] = [];
const a = await joinArena(arena.odinUrl, "client-a", s => seenByA.push(structuredClone(s)));
const b = await joinArena(arena.odinUrl, "client-b", s => seenByB.push(structuredClone(s)));
try {
assert.notEqual(a.id, b.id);
await until(() => Object.keys(a.state()?.players ?? {}).length === 2);
await until(() => Object.keys(b.state()?.players ?? {}).length === 2);
const before = structuredClone(b.state()!.players[a.id]!);
// Moves clamp at the arena edge, so pick a direction that always has room to move.
const dx = before.x > 0 ? -1 : 1;
await a.move(dx, 0);
await until(() => b.state()!.players[a.id]!.x !== before.x);
assert.equal(b.state()!.players[a.id]!.x, before.x + dx);
assert.deepEqual(a.state(), arena.state());
assert.deepEqual(b.state(), arena.state());
await b.leave();
await until(() => !(a.id !== b.id && b.id in a.state()!.players));
assert.deepEqual(Object.keys(a.state()!.players), [a.id]);
} finally {
await a.leave().catch(() => undefined);
await arena.close();
}
});
test("a player cannot walk out of the arena", async () => {
const arena = await startArena();
const a = await joinArena(arena.odinUrl, "client-a", () => undefined);
try {
for (let i = 0; i <= arenaSize; i++) await a.move(-1, 0);
assert.equal(arena.state().players[a.id]!.x, 0);
} finally {
await a.leave();
await arena.close();
}
});
test("serve.ts bundles the browser client and serves the page", async () => {
const { serveArena } = await import("./serve.ts");
const site = await serveArena(0);
try {
const html = await (await fetch(site.url)).text();
assert.match(html, /<canvas/);
const js = await (await fetch(`${site.url}/browser.js`)).text();
assert.match(js, /CultMeshBrowserClient/);
} finally {
await site.close();
}
});
test("the recipe page shows the source files verbatim", () => {
const page = readFileSync(new URL("../io-game.md", import.meta.url), "utf8").replace(/\r\n/g, "\n");
for (const file of ["schema.ts", "server.ts", "client.ts"]) {
const text = readFileSync(new URL(`./${file}`, import.meta.url), "utf8").replace(/\r\n/g, "\n");
assert.ok(page.includes(text), `docs/start/io-game.md no longer shows ${file} verbatim`);
}
});

View file

@ -1,17 +0,0 @@
import { joinArena } from "./client.ts";
import { arenaSize, type ArenaState } from "./schema.ts";
const canvas = document.querySelector("canvas")!;
const ctx = canvas.getContext("2d")!;
const scale = canvas.width / arenaSize;
const game = await joinArena(`ws://${location.host}/odin`, `browser-${crypto.randomUUID()}`, (state: ArenaState, me: string) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const [id, p] of Object.entries(state.players)) {
ctx.fillStyle = id === me ? "#f5c542" : "#6aa9ff";
ctx.fillRect(p.x * scale, p.y * scale, scale * 2, scale * 2);
}
});
const keys: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
addEventListener("keydown", e => { const d = keys[e.key]; if (d) void game.move(...d); });
addEventListener("pagehide", () => void game.leave());

View file

@ -1,28 +0,0 @@
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) {
const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` });
const mesh = await CultMeshBrowserClient.connect({
verseId, authorityRuntimeId, runtimeId, rendezvous,
trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots
});
let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document
const call = async (operation: string, payload: object) =>
decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload }));
const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey });
const { id } = await call("join", {});
lease.watch(record => {
if (!record) return;
latest = decodeCultNetPayload(record, arenaDocument);
onState(latest, id!);
});
return {
id: id!,
move: (dx: number, dy: number) => call("move", { id, dx, dy }),
state: () => latest,
leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); },
};
}

View file

@ -1,6 +0,0 @@
<!doctype html>
<meta charset="utf-8">
<title>arena</title>
<canvas width="500" height="500" style="border:1px solid #888"></canvas>
<p>Arrow keys. Open this page in a second window.</p>
<script type="module" src="/browser.js"></script>

View file

@ -1,64 +0,0 @@
{
"name": "cultlib-start-io-game",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cultlib-start-io-game",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.0"
}
},
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.22.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View file

@ -1,9 +0,0 @@
{
"name": "cultlib-start-io-game",
"private": true,
"type": "module",
"description": "docs/start recipe: a tiny two-client browser game over CultCache + CultMesh (TypeScript).",
"scripts": { "test": "node --test arena.test.ts" },
"dependencies": { "ws": "^8.18.0" },
"devDependencies": { "@types/ws": "^8.5.0" }
}

View file

@ -1,16 +0,0 @@
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
export interface Player { x: number; y: number }
export interface ArenaState { players: Record<string, Player> }
// The one shared document: every player's position. The server owns it; clients lease it.
export const arenaDocument = defineDocumentType({
type: "io.arena_state",
schemaId: "io.arena_state.v1",
schema: { parse: (value: unknown) => value as ArenaState },
});
export const arenaKey = "arena:main";
export const verseId = "io.arena";
export const authorityRuntimeId = "io.arena-daemon";
export const arenaSize = 100;

View file

@ -1,17 +0,0 @@
import { readFileSync } from "node:fs";
import { build } from "esbuild";
import { startArena } from "./server.ts";
// Serves the page, the bundled browser client, and the arena's WebSocket on one port.
export async function serveArena(port = 8080, host = "127.0.0.1") {
const bundle = await build({ entryPoints: [new URL("./browser.ts", import.meta.url).pathname], bundle: true, format: "esm", target: "es2022", write: false });
const html = readFileSync(new URL("./index.html", import.meta.url));
const arena = await startArena(port, host, (req, res) => {
if (req.url === "/") res.writeHead(200, { "content-type": "text/html" }).end(html);
else if (req.url === "/browser.js") res.writeHead(200, { "content-type": "text/javascript" }).end(bundle.outputFiles[0]!.contents);
else res.writeHead(404).end();
});
return { url: arena.odinUrl.replace("ws:", "http:").replace("/odin", ""), close: arena.close };
}
if (import.meta.main) console.log(`arena at ${(await serveArena()).url}`);

View file

@ -1,100 +0,0 @@
import { createServer, type RequestListener, type Server } from "node:http";
import { CultCache } from "@gamecult/cultcache-ts";
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
import { WebSocketServer, type WebSocket } from "ws";
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh).
export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) {
const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build();
await cache.put(arenaDocument, arenaKey, { players: {} });
const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); }));
await new Promise<void>(resolve => http.listen(port, host, resolve));
const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`;
const subscribers = new Map<WebSocket, string[]>();
const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m)));
const record = () => ({
schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId,
schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(),
payloadEncoding: "messagepack" as const,
payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never,
sourceRuntimeId: authorityRuntimeId,
});
const publish = async (next: ArenaState) => {
await cache.put(arenaDocument, arenaKey, next);
for (const [ws, ids] of subscribers) for (const subscriptionId of ids) {
send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() });
}
};
let nextId = 0;
const operations: Record<string, (p: any) => Promise<object>> = {
join: async () => {
const id = `p${++nextId}`;
const state = cache.getRequired(arenaDocument, arenaKey);
await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } });
return { id };
},
move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => {
const state = cache.getRequired(arenaDocument, arenaKey);
const me = state.players[id];
if (!me) return { ok: false };
const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v));
await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } });
return { ok: true };
},
leave: async ({ id }: { id: string }) => {
const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players;
await publish({ players: rest });
return { ok: true };
},
};
const wss = new WebSocketServer({ server: http });
wss.on("connection", (ws, req) => {
const odin = req.url === "/odin";
ws.on("close", () => subscribers.delete(ws));
ws.on("message", async raw => {
const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer)));
if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") {
send(ws, {
schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId,
verses: [{
verseId, displayName: "Arena", authorityModel: "ServerAuthoritative",
compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] },
discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId],
authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }],
}],
});
} else if (m.schemaVersion === "cultmesh.session_open.v2") {
send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId,
protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" });
} else if (m.schemaVersion === "cultnet.database_subscribe.v0") {
subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]);
send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] });
} else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") {
subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId));
} else if (m.schemaVersion === "cultnet.operation_request.v0") {
const handler = operations[m.operation];
const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined;
send(ws, {
schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation,
status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64",
payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId,
});
}
});
});
return {
odinUrl: `${endpoint}/odin`,
state: () => cache.getRequired(arenaDocument, arenaKey),
close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); },
};
}

View file

@ -33,9 +33,9 @@ Recovered from the legacy "Database Tools" window (Aetheria `d3db1730`,
**The attribute is not `[CultIndex]`.** `[CultIndex]` means unique (operator, 2026-09-30;
`CultDocumentContracts.cs:34`). Grouping is `CultInspectorGroupByAttribute`, in
`CultInspectorAttributes.cs`, read only by `CultInspectorModel`. It changes no schema id,
catalog entry, index, or store byte. The index cut converts the grouping and selection `[CultIndex]`
declarations (CultMesh, Geometry) to `[CultRole]` rather than deleting them, and adds no
`[CultInspectorGroupBy]` in their place: those are service stores, not Studio-authored catalogs.
catalog entry, index, or store byte. The index cut's deletion of grouping-only `[CultIndex]`
declarations (CultMesh, Geometry) adds no `[CultInspectorGroupBy]` in their place: those are
service stores, not Studio-authored catalogs.
### Invariants

View file

@ -1,132 +0,0 @@
# Write-ownership map
Imagination, 2026-10-27/28, session `self-2026-10-27c-eureka`. Base: CultLib `main` at `b7a68884` (C2a landed).
Serves `variants:follow_up:write-ownership-cut`. Spec: `variants:cut_spec:cut-write-ownership-cs.r1`. Question:
`variants:question:written-type-undeclared-drift`.
## The rulings: what decides and what illustrates
- `variants:ruling:cache-refuses-retype`. Her words decide: a stored record's schema id identifies its type, so a write of
another type at that key is the wrong type and is refused, by a cache exactly as by a cacheless write. Retyping is an
explicit remove plus add. The Household / `_pet` / rake analogy is an illustration, not a rule about object fields. Self's
gloss (ownership becomes per staged key) is Self's reading, not hers.
- `variants:ruling:write-ownership-by-written-type`. Her words decide one thing: a record under the written type's own id is
equivalent by construction, so which type owns that id cannot change the write. The full rule ("own id or one the written
type declares it migrates from; anything else is refused; no registry, Shared included, and no owner/lister precedence
enters a write's ownership decision") is Self's reading, which she accepted in the same exchange. Her remark that ids
indicating structural equivalence beats demanding names match is context for the open
`variants:question:store-schema-id-derivation`, not a ruling here.
- In force and touched: `variants:ruling:drift-record-write-ownership` (operator: a type that reads a record as compatible
drift owns it and restamps it, as 2.0.1 does), `variants:ruling:mesh-writes-bound-by-declaration`,
`variants:ruling:undeclared-read-only`, `variants:ruling:unheld-removal-refused-at-commit`,
`variants:ruling:unheld-removal-multi-store-cost-ruled`.
The two Self readings disagree on one case: a record under an undeclared older id of the written schema name, read whole
(an undeclared version bump). Literal "anything else is refused" makes it read-only; `drift-record-write-ownership` makes it
owned. That is `variants:question:written-type-undeclared-drift`, and only the operator can settle it.
## Body facts (probed at b7a68884)
Probes: scratch tests in this worktree, removed. C# filtered runs on Starfire (one job each); TS and Rust on Yggdrasil
(`ygg-verify.sh`, scratch commit `78f604fd`, not pushed to the forge); Python locally. Types A (`tests.wo_a`) and B
(`tests.wo_b`) share one shape; N1/N2 are two versions of `tests.wo_n`, nothing declared.
| Probe | C# | TypeScript | Rust | Python |
|---|---|---|---|---|
| P1 cache {A,B}: B at k, then A at k | **C**: B's record replaced, k now A's id | R `SchemaConflictError` (cultcache-ts: one record per key) | R `SchemaConflictError` naming k, A not written (leaf cut) | R `SchemaConflictError`, A's write refused, B kept (was C, both live) |
| P2 cache {A} only, store holds B at k, write A at k | R `CultSchemaConflictException` (B is foreign, carried) | R `SchemaConflictError` at write (foreign loads, carried; cultcache-ts) | R `SchemaConflictError` (B loads as foreign, carried; leaf cut) | R `SchemaConflictError` (B is foreign, carried; the load admits it; was R at load) |
| P3 cacheless `WriteDocument<A>` over B, registry null / {A} / {A,B} | R / R / R | no cacheless write | no cacheless write | no cacheless write |
| P4 cache {A,B}: remove k and upsert A at k in one batch | C (k is A's) | n/a | n/a | n/a |
| P5 cache {A,B}: remove, then add in a second commit | C | n/a | n/a | n/a |
| P6 N2 over an N1 record (undeclared name drift), cache {N2} / cacheless reg {N2} | C restamped / C restamped | not probed | not probed | C (cache) |
The C#-written probe the leaves are held to is `tests/vectors/key-identity` (12 rows: retype, foreign carry, key in two stores), walked by
`KeyIdentityVectorTests`; each leaf cut corrects its own column above.
What this establishes:
- C#: one record per key. The only path that retypes is the cache arm (P1): `CacheBackingStore.RefuseUndeclared`
(`CultCache.cs:3994-4009`) and the on-disk judge (`OwnsOnDisk`, `:4731-4763`) ask `Registry.Owns(schemaId, catalog)`
(`:841-842`), which answers per schema id for the whole registry: any registered type declaring the id makes every
registered type's write to that key owned. The cacheless arm already refuses the retype (P3) but decides it through
`CatalogEntryLookup`'s registry half (`:596-634`) and the claimant precedence, which is what the ruling removes.
The judge is also per batch, not per key: the cacheless predicate's `declared` is the union over every written entry
(`:4752-4754`), and `UndeclaredOnDisk` memoises one answer per schema id (`:4784-4797`).
- Retype as remove plus add already works in the cache (P4, P5); the cut adds no new primitive.
- Directory store: its own judge (`DirectoryMessagePackBackingStore.cs:287-306`) asks `Registry.Owns` for every key,
upserts included. It must take the same per-key rule.
- TypeScript and Python: records are keyed by `(type, key)` (TS `#entryId(definition.type, key)`, `cult-cache.ts:323`;
Python sorts and stores by `(type, key)`). A write of A at k never meets B's record: both live at k (P1). Neither carries
a foreign record; a store holding an unregistered type does not load (P2). So neither has a "write meets another type's
record" path to fix: bringing the ruling to them first needs C#'s key identity (one record per key) and C#'s foreign
carry, parity changes of their store model, not a write-ownership rule (follow-up `variants:follow_up:leaf-key-identity-parity`).
- Rust is that follow-up's first leaf cut (`cut-leaf-key-identity-rs`). The cache keeps its `(type, key)` map; one judge
(`refuse_retype`) refuses another registered type at a key the cache holds, or any write at a key a store carries
foreign, on every write path, pull and SoA load, comparing types, not the ids envelopes claim. At a key the cache does not
hold (a fresh key, or one another writer filled after this cache's pull) the single-file store's commit guard judges by
the stored and claimed schema ids, so an envelope claiming another registered type's id is refused earlier, at every
envelope admission (`refuse_claimed_id`, the pull's own `resolve_registered_type` lookup); an id naming no registered type
passes, trusted as claimed: an envelope under such an id can replace another writer's record stored under it at a key
this cache does not hold, or be served as another type by a reader that registers the type the id names (Soul g8a, g8c,
open: which ids a Rust type owns is not declared to CultCache). Store-level writers called directly (`push`,
`insert_entry_if_absent`, compare-and-swap) trust the claimed id.
The cache carries an unregistered type's records as foreign and walks the vector's 12 rows.
- Interop (unproven): a Python store holding A and B at k fails C# decode first on payload encoding (Python's
lambda-defined document wrote JSON payloads), so what C# does with two records at one key was not reached.
## Model and authority table
| Value | Today (b7a68884) | After the cut |
|---|---|---|
| May an upsert of T replace the stored record at k? | cache: `Registry.Owns(id)` (any registered type); cacheless: union of written entries' declarations plus claimant over written entries and the lent registry | **Owner: `CultDocumentRegistry.WrittenOwns(T's entry, stored id, catalog)`**, per key, both arms, both store kinds |
| May a removal (or check) at k proceed? | cache: `Registry.Owns`; cacheless: the written-entries predicate | unchanged in kind: cache `Registry.Owns`; cacheless: owned when some entry the call writes `WrittenOwns` it (no registry) |
| Which type reads a record (claimant precedence) | `Claimant<T>` | unchanged; read machinery only, no write asks it except through `WrittenOwns`' one-entry lookup (option `own-entry-reads-whole`) |
| Lent registry in a cacheless write | encodes, decides copied-record v3 header, adds claimants | encodes and decides the copied-record header (`CopiedHoldIds`, `:4600-4630`); **adds no claimants** |
| `CatalogEntryLookup` registry half (`_others`, `Other`, `Others`) | live | **deleted** |
| `CultDocumentRegistry.Lookup` | live | **deleted** |
| `Owns<T>`'s `mayClaim` | live | **deleted** (the lookup holds only the written entry) |
| Foreign record at k | refused for every write | unchanged |
Authority map (also in the spec):
- Owner: `WrittenOwns(written, storedId, catalog)` decides whether an upsert of `written` may replace the record stored at
its key. Inputs: the written catalog entry, the stored record's schema id, the catalog that publishes it. Nothing else.
- Derived: `Registry.Owns` is demoted to the removal/check rule and to load-time read-only marking; it no longer decides
upserts. `Claimant` is read machinery.
- Forbidden writers: `Registry.Owns` on an upsert key; `CatalogEntryLookup` with a registry; the per-batch union `declared`;
the per-schema-id memo across different written entries.
- Shared paths: cache staging (`Claim` -> `RefuseUndeclared`, `:3156`), single-file on-disk judge (`ApplyWriteSet` ->
`RefuseUndeclaredOnDisk`, `:4565`, `:4736`), single-file `JudgeChecks` (`:4726`, checks only), directory store judge,
`WriteRecord`, `WriteDocument`, unattached `PushAll`, Mesh's `WriteSingleFileDocument` and raw-payload write.
## Absorb or leave
- `variants:follow_up:drift-write-ownership-leaves`: **leave**, but it now waits on `leaf-key-identity-parity`. Its premise
(give each leaf the C# Owns rule) assumes one record per key, which no leaf has (P1). The leaf write-ownership cuts in the
sequence below close it together with this follow-up's leaf half.
- `variants:follow_up:public-store-writes-behind-cache`: **leave**. It asks who may stage behind an attached cache (API
shape); this cut changes what any staging is judged by. No collision: direct `Push`/`CommitBatch` reach the same on-disk
judge, so they get the per-key rule for free. Hands must not give direct staging a different predicate.
- `variants:follow_up:writer-dangling-link-tolerance`: **settled** (the reader owns the verdict; every runtime's whole-store
`push_all` refuses a dangling link, as `push` does).
## Deferred doc findings this cut fixes
The ownership-docs rewrite in part 1 is the fix for (each resolved `Deferred` to `write-ownership-cut`):
`variants:finding:cut-claimant-one-owner.s2.changelog-underdescribes-refusals`,
`variants:finding:cut-claimant-one-owner.s3.changelog-misstates-subset`,
`variants:finding:cut-claimant-one-owner.s5.refusal-list-ignores-declared-win`,
`variants:finding:cut-claimant-one-owner.s5.shared-read-stated-as-contract`.
Sites: `unity/org.gamecult.cultlib/CHANGELOG.md:16`, `src/GameCult.Caching/Contracts/cultcache-persistence-format.md:209-219`,
`CultCache.cs` `WriteRecord`/`WriteDocument` registry docs (`:4476-4519`). Self closes them `Fixed` after Soul holds.
## Cut sequence
1. `cut-write-ownership-cs` (spec r1, admitted, blocked on `variants:question:written-type-undeclared-drift`): C# reference,
both arms, both store kinds, docs. Lands before the 3.0.0 release cut.
2. `leaf-key-identity` (one per leaf, TS, Rust, Python; follow-up `leaf-key-identity-parity`): one record per key and
foreign carry at C# parity, with a cross-runtime vector. Not mapped.
3. `leaf-write-ownership` (one per leaf, after 2): `WrittenOwns` per leaf; closes `drift-write-ownership-leaves`. Not mapped.
Leaves ship on their own versions (cultcache-ts 0.14.2), not in the 3.0.0 Unity package, so 2 and 3 do not gate 3.0.0;
whether they must land first is Self's call.

43
package-lock.json generated
View file

@ -482,18 +482,6 @@
"resolved": "packages/cultcache-ts",
"link": true
},
"node_modules/@gamecult/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/@gamecult/cultmesh-ts": {
"resolved": "packages/cultmesh-ts",
"link": true
},
"node_modules/@gamecult/cultnet-ts": {
"resolved": "packages/cultnet-ts",
"link": true
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
@ -877,6 +865,18 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/cultmesh-browser": {
"resolved": "packages/cultmesh-browser",
"link": true
},
"node_modules/cultmesh-ts": {
"resolved": "packages/cultmesh-ts",
"link": true
},
"node_modules/cultnet-ts": {
"resolved": "packages/cultnet-ts",
"link": true
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
@ -1184,7 +1184,7 @@
},
"packages/cultcache-ts": {
"name": "@gamecult/cultcache-ts",
"version": "0.15.0",
"version": "0.14.2",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
@ -1200,16 +1200,15 @@
}
},
"packages/cultmesh-browser": {
"name": "@gamecult/cultmesh-browser",
"version": "0.4.0",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.15.0",
"@gamecult/cultnet-ts": "^0.4.0"
"@gamecult/cultcache-ts": "^0.14.0",
"cultnet-ts": "^0.3.0"
},
"devDependencies": {
"@gamecult/cultmesh-ts": "^0.2.0",
"@types/node": "^24.0.0",
"cultmesh-ts": "^0.2.0",
"typescript": "^5.9.0"
},
"engines": {
@ -1217,12 +1216,11 @@
}
},
"packages/cultmesh-ts": {
"name": "@gamecult/cultmesh-ts",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.15.0",
"@gamecult/cultnet-ts": "^0.4.0",
"@gamecult/cultcache-ts": "^0.14.0",
"cultnet-ts": "^0.3.0",
"koffi": "3.3.0"
},
"devDependencies": {
@ -1235,11 +1233,10 @@
}
},
"packages/cultnet-ts": {
"name": "@gamecult/cultnet-ts",
"version": "0.4.0",
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"@gamecult/cultcache-ts": "^0.15.0",
"@gamecult/cultcache-ts": "^0.14.1",
"@msgpack/msgpack": "^3.1.3",
"ajv": "^8.17.1"
},

View file

@ -10,7 +10,7 @@
"scripts": {
"build:ts": "node scripts/run-typescript-workspaces.mjs build",
"test:ts": "node scripts/run-typescript-workspaces.mjs test",
"test:pack:ts": "node --test scripts/test-declared-dependency-ranges.test.mjs && node scripts/test-typescript-package-closure.mjs && node scripts/test-declared-dependency-ranges.mjs",
"test:pack:ts": "node scripts/test-typescript-package-closure.mjs",
"test:eve-browser-network": "node scripts/verify-eve-browser-network.mjs"
},
"devDependencies": {

View file

@ -1,49 +0,0 @@
# Changelog
## Unreleased
### Changed
- `parse_stored_at` (was `_parse_stored_at`) is public: cultmesh-py judges a peer's `storedAt` by it. A peer's `document_put_raw` whose `storedAt` is missing, empty or not a stamp is refused `message_invalid` (field `document`) and nothing is minted or stored (variants:ruling:raw-put-without-stamp-refused). A subscribed server's stampless put now raises `ValueError` from `CultMeshClient.read_next_change` instead of being stored under a minted stamp.
### Added
- `CultCache.update` and `update_global` return `UpdateResult(outcome, document)`; `UpdateOutcome` is `UPDATED`, `DECLINED` or `LOST_RACE`. The single-file store gains `read(key)` (the record and its content version, the SHA-256 C# computes) and `commit_if(envelope, expected)`, the version is the SHA-256 of the record re-encoded as `[key, schemaId, storedAt, payload]` with minimal MessagePack encodings, which is the SHA-256 of the record's stored bytes exactly when the writer encoded it minimally (every writer in this library does; a record another writer encoded with a wider-than-needed length or integer has the version of its minimal form, not of its bytes). Both looked up by the cache and not `BackingStore` protocol members (variants:cut_spec:cut-update-with-retry-py).
- `read_payload_tree(type_name, payload, decoder=None)`: the exported owner of the one-value, depth-first payload read that `decode_payload` uses, for payloads read outside a definition (`cultnet-py`'s witness bundle decode needs it, so `cultnet-py` now requires `cultcache-py>=0.4.0`). The manifest version is 0.4.0, the minor these Unreleased breaking changes imply, set in this change so the workspace install resolves the new bound; the `## Unreleased` heading becomes `## [0.4.0]` when it is tagged.
### Changed
- A write that replaces a record is stored at a later `stored_at` than the record it replaces, in the single-file store, the JSONL store and a store-less cache, with no exception for identical content (`variants:ruling:py-rs-update-follow-csharp-restamp`); the cache holds the stamp the store wrote. A stored_at is read only in the shape the runtimes write (an RFC 3339 date-time with T, a fraction of 1 to 9 digits, Z or a numeric offset), the same on every Python; any other text, or an instant outside `datetime`'s range, is not a stamp and a write over it is stamped at now. A write over a record stamped at the last instant `datetime` can hold (9999-12-31T23:59:59.999999Z) is refused with `ValueError` and writes nothing.
- `store_write_lock` looks for a link at the store path again once the lock is held, so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, instead of being replaced. `JsonLinesBackingStore` takes no cross-process lock (only its thread lock, under which it already refuses a link), so it has no lock wait to re-check across. Pinned by a test that puts the link while a foreign process holds the lock.
- `CultCache.is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store.
- `JsonLinesBackingStore` writes LF line endings on Windows. It wrote CRLF there, through `write_text` newline translation, before the commit_store_file change (#154, adbbb780).
- `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import.
- `JsonLinesBackingStore` writes through the same `commit_store_file` as the single-file store: a unique staged file, fsynced before the replace and removed when the replace fails, so a refused replace no longer leaves `<path>.tmp` behind. It staged a fixed `<path>.tmp` without an fsync.
- The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `<path>.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `<path>.tmp-<pid>-<uuid>`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `<path>.tmp` is no longer used.
### Breaking
- `update(document, key, apply, *, max_attempts=5)` and `update_global(document, apply, *, max_attempts=5)` replace the blind pair. Each attempt reads the record from the home store, runs `apply` on a copy (None when absent) outside every lock, and commits only if the record is unchanged, running `apply` again on a lost race; it returns `UpdateResult`, not the value. `apply` returning None writes nothing; a lost race that outlasts `max_attempts` returns `LOST_RACE`; a store without `read` and `commit_if` (JSON lines among them) refuses before `apply`. `put` is the blind write.
- `push` returns the `stored_at` it wrote and `push_all` a dict of them by key (`{}` when a soft push skipped); a store returning None keeps the cache's own stamp.
- `push_all(envelopes, soft=False)` on the single-file and JSONL stores and on the `BackingStore` protocol takes the soft flag, as TypeScript, Rust and C# do: a soft push writes only where nothing is at the path; over a store the reader reads, empty included, it returns without writing, and over a file the reader cannot read it refuses (`StoreUnreadableError` for the single-file store). The `BackingStore` protocol has `pull_or_absent()`: the envelopes the reader reads, `None` when nothing is at the path, a raise when the path cannot be read; its default is `pull_all()`, which cannot say absent, so a store that inherits it is always read. The default `push_all` decides soft by it (writes where it says absent, skips over a store it read, refuses where it raises), and the single-file and JSONL stores expose their pull through it; an inheriting store that can tell nothing-at-the-path overrides `pull_or_absent`, not `push_all`. Before, a soft push through the default always skipped. A store kind that overrides `push_all(envelopes)` without the keyword fails when a caller passes `soft`.
- A write at a store path that is a link, live or dangling, is refused with the new `StoreLinkedError` (an `OSError`, exported from `cultcache_py`; `.path` names the path) before the lock and the replace, for the single-file store and for `JsonLinesBackingStore` (`push`, `push_all`, `delete`), and nothing is written. Before, a write at a live link replaced the link with a regular file, and at a dangling link both stores raised `FileNotFoundError`. A caller catching `FileNotFoundError` there now catches `StoreLinkedError` (`OSError`); configure the link's target as the store path. On Windows a junction or any reparse point counts as a link. Reads through a live link work.
- A record whose payload carries a slot past its reader's declared slots, at any depth (the document, a nested record, a list element, a union arm, a unit arm), is read, held read-only, and every write or removal of its key (`put`, `put_global`, `update`, `put_envelope`, `put_envelopes`, `delete`, `delete_global`) is refused with `SchemaConflictError` before any store is touched (`cultlib-gaps:ruling:extra-trailing-slots`). Reading dropped those slots silently and the next write shed them. `SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` and the message names the type and both counts, never a slot value. A depth-0 mark is consented, and the record writable, only when the record is stored under an id the document lists in `compatible_schema_ids` and that id's catalog entry has slots past the document's, covering the mark's slot count (`cultlib-gaps:ruling:slot-mark-consent`); a nested mark, or a mark under the document's own id, keeps the record read-only whatever is declared. Missing trailing slots keep their declared defaults. An untouched marked record keeps its stored bytes through every other write. Added `CultCache.is_writable(document, key)` (advisory; the write still decides: it also answers False for a foreign-carried key, for a key held as a type the document does not own, and for a global document at a non-global key; `delete` asks the same judges, so removing a key held as another type is refused with `SchemaConflictError` unless the document owns that record's schema id (its own or one it lists compatible), in which case the removal removes that record, its file row and its lookups, as a write of the key would replace it; it returned silently and removed nothing), `DocumentDefinition.decode_payload_marked`, and `UnreadSlots`. A document with a custom `decode` (no slot codec) never marks. A store holding a variant record is still unreadable here. Implies a minor bump while the version is 0.x (0.3.0 to 0.4.0): code that wrote back a record from a newer writer now gets `SchemaConflictError`.
- A payload is one MessagePack value: `DocumentDefinition.decode_payload` refuses bytes after it, by count, with `ValueError` `<type> payload has N bytes after its first MessagePack value (refused on read).` (never the bytes, never the depth refusal; a payload that ends early stays the decoder's), as the C# reference does (`tests/vectors/union-parity/bytes-after-outcomes.msgpack`). It raised `msgpack.ExtraData` (a `ValueError`, so only a caller naming `msgpack.ExtraData` is affected); this is a minor-version bump while pre-1.0. The refused set is unchanged. Cache loads, `get` and every definition built on the MessagePack decoder go through it; a user `payload_decoder` that raises `ExtraData` is refused the same way. Depth is judged first, so a value nested past 256 with a tail is the depth refusal, as in C#.
- A cache holds one record per key whatever its type (`variants:ruling:cache-refuses-retype`). A `put`, `put_envelope`, `put_envelopes` or `put_global` of a second type under a key the cache holds, or under a key a store carries foreign, is refused with `SchemaConflictError` naming the key, even with no store, and changes nothing; retype a key by deleting it, then putting the new type. A write may replace another registered type's record only when the written type owns its schema id (declares it or lists it compatible) and both types share one home store. A load that reads one key from two stores, whatever the types, is refused with `CultCacheError` naming the key and both types, and admits nothing. A store that holds a record no registered type claims now loads and is carried: the cache does not serve it, the store keeps its bytes through every write, `delete` or `put` at its key is refused with `SchemaConflictError`, and registering a type that declares its schema id claims it at the next load. It raised `CultCacheError` "Unknown persisted document type". Two global types share the key `__global__`, so a second global type in one cache is now refused, and a store that already holds two global types at `__global__` (a JSONL store written by caches that never pulled could produce one) no longer loads: the pull raises `CultCacheError` and admits nothing. C# refuses a second persisted global on pull the same way (`cultcache-store-composition.md`, "Globals never invented"); repair such a file by deleting one of the two records with the runtime that wrote it.
- Added `foreign_records()` and the frozen `ForeignRecord(key, schema_id, schema_name, stored_at)`, sorted by key in UTF-16 code-unit order. `stores.retyped` and `stores.written_owns` lose their underscore: the cache judge and the single-file store's commit guard ask one predicate.
- A single-file store holding two records under one key is unreadable: the v1 reader and the legacy envelope-array reader refuse it with `StoreUnreadableError` (a key is compared by its exact code points, no case folding or normalisation). A catalog slot whose canonical schema is not a string is refused the same way. A store that loaded before can now be refused: repair it with the runtime that wrote the duplicate.
- A `put` or `push_all` that would land a second type under a key the store holds is refused with `SchemaConflictError`, and the file is left as it was. Retype a key by deleting it, then putting the new type. A write replaces the record at a key only when the written type owns it: its catalog entry declares the stored schema id (its own, or one it lists compatible), so a renamed type that lists the old id compatible may replace the record, or it has the stored record's schema name. This runtime does not compare shapes, so a same-name type is never refused for dropping persisted slots as C# refuses it.
- Writes of the same type replace by key: the last record wins, in a batch as in the file.
- `indexes` are unique, as C# `[CultIndex]`: a write, load or `register_index` that leaves two records of a type holding one non-blank value under one alias is refused (`CultCacheError`) naming both keys and the alias, and writes nothing. Index text is the C# canonical text (`True`/`False`, integers as digits, floats as the exact expansion, blank values never indexed), so an empty or whitespace string is no longer a value. A new `roles` map names the many-row selection aliases (never indexed for lookup, same catalog `index_alias`). Classify every `indexes` key per the README before updating. The verdict is the contract, the text is informational.
- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `push_all` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again.
- Added `StoreUnreadableError` (a `ValueError`) for a store file this runtime cannot read, and `SchemaConflictError` (a `ValueError`) for a
write the catalog cannot describe. A store is exactly three slots; a schema is never recovered from a record's payload.
- The single-file writer derives the catalog from the records it writes: one entry per carried schema id, the registered entry over an
arrived one, written as chosen and never merged. A cache stamps a record with its registered schema's id.
- Every registration refusal is now a `SchemaConflictError`, a `ValueError`, where it was a `CultCacheError`, a `RuntimeError`: registering a document type or a schema name twice no longer raises what `except CultCacheError` or `except RuntimeError` catches.
- Two documents that own one schema id are refused at registration (`SchemaConflictError`). 2.0.1 accepted them. A document may still list another's id as compatible.
- A loaded record resolves by its schema id first: the document that owns the id, else the one document that lists it as compatible; several listers and no owner refuse the record with `SchemaConflictError`. The record's type name resolves it only when its schema id names no registered document. 2.0.1 resolved by the record's type name alone.

View file

@ -19,12 +19,7 @@ discovery, sessions, local server) build on it, mirroring `cultcache-rs` /
`[format, schemaCatalog, records]`
- records store raw MessagePack payload bytes under schema-catalog identity
- payloads decode only through the registered document definition for that type
- a payload is exactly one MessagePack value: bytes after it are refused on read, by count
- a cache holds one record per key whatever its type: a write of a second type at a held key is refused with
`SchemaConflictError` (in memory too), and two stores holding one key refuse the load
- a store record no registered type claims loads and is carried as foreign: `cache.foreign_records()` lists it, the store
keeps its bytes through every write, a write or removal at its key is refused, and registering a type that declares its
schema id claims it at the next load
- unknown persisted types fail closed
- global documents are singleton-style per type
- 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
@ -78,7 +73,7 @@ store.
- `register_document_type(...)`
- `register_registry(...)`
- `register_name_lookup(...)`
- `register_index(...)` (a unique index)
- `register_index(...)`
- `add_backing_store(...)`
- `add_generic_store(...)`
- `pull_all_backing_stores()`
@ -94,39 +89,12 @@ store.
- `put(...)`
- `put_envelopes(...)`
- `put_global(...)`
- `update(document, key, apply, *, max_attempts=5)` and `update_global(document, apply, *, max_attempts=5)`: the conditional golden path; reads the record as its home store holds it now, runs `apply` on a fresh copy, writes only if the record is unchanged and runs `apply` again on a lost race; returns `UpdateResult(outcome, document)` with `UpdateOutcome.UPDATED`, `DECLINED` (`apply` returned `None`) or `LOST_RACE` (see below)
- `update(...)`
- `update_global(...)`
- `delete(...)`
- `delete_global(...)`
- `is_writable(document, key)` (advisory: would a write or removal of the key be refused; see below)
- `snapshot()`
## Indexes And Roles
`indexes=` are unique lookups (variants:ruling:unique-indexes; C# `[CultIndex]`): within one document type and one alias
no two records hold one non-blank value, judged on the result of every write (a batch is judged whole, so a swap inside
one `put_envelopes` lands) and of every load and `register_index`. A refusal is a `CultCacheError`,
`Records <k1> and <k2> (<schema name>) both hold unique index '<alias>'; an index value names one record.`, keys in
ordinal order, never the value; it writes nothing, and a refused load or registration installs nothing. The contract is
the verdict; the text is informational. Blank values (absent, empty, or only C# `White_Space` characters) are never
indexed and never conflict. Two types may share an alias and a value. Values compare as the C# canonical text: strings as
is (never trimmed), `True`/`False`, integers as digits, a non-integral float as its exact decimal expansion, NaN and
infinities no value.
`roles=` name selection aliases (C# `[CultRole]`): many records may share a value, nothing is indexed or refused,
`get_by_index` on a role answers `None`, and the member's catalog `index_alias` is the same either way, so catalog bytes
do not move. An alias is declared in one of the two. `CultMeshDatabase.watch_by_index` reads `indexes` only.
Migrating an existing `indexes` key, per key, before bumping:
1. Nothing looks records up by the key (`get_by_index`, `get_key_by_index`, `node.get_by_index`, `watch_by_index`): move it to `roles`.
2. Something looks it up: it stays unique only when two live records can never hold one non-blank value (the record key
is derived from that value alone, or it is an outside identity: email, sku, external id). Enums, statuses, kinds,
categories, parent or foreign ids, shared hashes and timestamps are roles; select them with `get_all` plus a filter.
3. Open each real `.cc` once with the new definitions. A refusal names both keys and the alias and writes nothing.
Misclassifying: a role declared unique makes a store that holds a shared value refuse to open until the key is
reclassified; a unique key declared a role answers no lookups and stops refusing duplicates.
## Persistence Model
Backing stores persist envelopes, not caller domain objects. The cache decodes payloads through registered document definitions and rejects unknown type discriminators. This keeps Python's dynamic runtime from turning persistence into an open polymorphic sewer with a cheerful docstring.
@ -142,14 +110,8 @@ used by the TypeScript, Rust, and C# runtimes:
canonicalSchemaJson, compatibleSchemaIds, members]`
- records: `[key, schemaId, storedAt, payload]`
`update` needs a home store with the optional `read(key)` (the record at the key, of any type, and its content version, from one read) and `commit_if(envelope, expected)` (write only if the record at the key is still that version, under the store's write lock). The content version is the lowercase hex SHA-256 of the record re-encoded as the store writes it, `[key, schemaId, storedAt, payload]` with minimal MessagePack encodings, the same value C# computes (`tests/vectors/content-version`). It equals the SHA-256 of the record's bytes in the file exactly when the writer encoded the record minimally, as every writer in this library does; a record encoded with a wider-than-needed length or integer has the version of its minimal form. The single-file store has both; `JsonLinesBackingStore` and a store without them refuse `update` before `apply` runs. A cache with no store copies its held instance. Every write that replaces a record is stored at a later `stored_at` than the one it replaces, in each store kind and in a store-less cache; `push` and `push_all` return the stamp written, and the cache holds that stamp.
Large corpora should use a sharded store or a real database. A single snapshot file is a scalpel, not a forklift.
## Store format and rollback
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-py from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
## DatabaseEntry Slot Contract
For cross-runtime cache entries, use `define_database_entry_type(...)` instead
@ -183,16 +145,6 @@ The payload is a MessagePack array. Field keys are durable slot indexes:
- newly added fields should use new keys
- fields with defaults tolerate missing or nil slots when older payloads are read
A payload carrying a slot past the slots a reader declares, at any depth (the document, a nested record, a list element, a
union arm, a unit arm), was written by a newer build. It is read, and the record is held read-only: `put`, `update`,
`put_envelope(s)`, `delete` and their global forms refuse its key with `SchemaConflictError` before any store is touched, and
`SchemaConflictError.unread_slots` is an `UnreadSlots(type_name, slot_count, declared_count, occurrences, depth0_only)` naming the
first type read with extra slots (never a slot value). `CultCache.is_writable(document, key)` answers the same question before
the write: False for such a record, for a key a store carries foreign, and for a key held as another type's record; the write
still decides. `DocumentDefinition.decode_payload_marked(payload)` returns the value and its `UnreadSlots` (or None). Declaring the
stored id in `compatible_schema_ids` consents to the loss of depth-0 slots that id's catalog entry reports; a nested mark never is.
A store keeps a read-only record's bytes through every other write. A document with a custom `decode` never marks.
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.

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cultcache-py"
version = "0.4.0"
version = "0.3.0"
description = "Python CultCache runtime for GameCult typed state."
readme = "README.md"
requires-python = ">=3.10"

View file

@ -1,7 +1,7 @@
from .backing_store import BackingStore, CultCacheEnvelope
from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
from . import leaves
from .cache import CultCache, CultCacheBuilder, ForeignRecord, UpdateOutcome, UpdateResult
from .cache import CultCache, CultCacheBuilder
from .documents import (
MAX_PAYLOAD_DEPTH,
MAX_SLOT_KEY,
@ -9,17 +9,15 @@ from .documents import (
DatabaseEntryField,
DocumentDefinition,
UnionArm,
UnreadSlots,
database_entry_field,
define_database_entry_type,
define_document_registry,
define_document_type,
define_record_codec,
define_union,
read_payload_tree,
)
from .leaves import SlotCodec
from .stores import JsonLinesBackingStore, SchemaConflictError, SingleFileMessagePackBackingStore, StoreLinkedError, StoreUnreadableError
from .stores import JsonLinesBackingStore, SingleFileMessagePackBackingStore
__all__ = [
"BackingStore",
@ -31,20 +29,12 @@ __all__ = [
"CultCacheSchemaCatalogMember",
"DatabaseEntryField",
"DocumentDefinition",
"ForeignRecord",
"JsonLinesBackingStore",
"MAX_PAYLOAD_DEPTH",
"MAX_SLOT_KEY",
"SchemaConflictError",
"SingleFileMessagePackBackingStore",
"SlotCodec",
"StoreLinkedError",
"StoreUnreadableError",
"UnionArm",
"UnreadSlots",
"UpdateOutcome",
"UpdateResult",
"read_payload_tree",
"leaves",
"database_entry_field",
"define_database_entry_type",

View file

@ -58,36 +58,19 @@ class CultCacheEnvelope:
class BackingStore(Protocol):
"""The members every store has. A store may also offer `read(key) -> tuple[CultCacheEnvelope, str] | None` (the record at the
key, of any type, and its content version, from one read) and `commit_if(envelope, expected: str | None) -> str | None` (write
only if the record at the key is still the version `expected`, None meaning no record; returns the stored_at written, or None
with nothing written). They are looked up with getattr, not Protocol members, and `CultCache.update` needs both. `push` and
`push_all` may report the stored_at they wrote (`str` and `dict[str, str]` by key); None keeps the cache's own stamp. A store
stamps a record that replaces another strictly later than the one it replaces."""
def pull_all(self) -> list[CultCacheEnvelope]:
...
def push(self, envelope: CultCacheEnvelope) -> str | None:
def push(self, envelope: CultCacheEnvelope) -> None:
...
def delete(self, type: str, key: str) -> None:
...
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
"""The envelopes the reader reads (empty included), None when nothing is at the path; raises when the path cannot be read.
The default is pull_all, which cannot say absent: a store that inherits it is always a store the reader read. A store kind
that can tell nothing-at-the-path overrides this, and the default push_all then writes where nothing is on a soft push."""
return self.pull_all()
def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> dict[str, str] | None:
# Soft: write only where pull_or_absent says nothing is; skip over a store it read, empty included; its raise refuses.
pulled = self.pull_or_absent()
if soft and pulled is not None:
return
pulled = pulled or []
existing = {(envelope.type, envelope.key): envelope for envelope in pulled}
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None:
existing = {
(envelope.type, envelope.key): envelope for envelope in self.pull_all()
}
for envelope in envelopes:
existing[(envelope.type, envelope.key)] = envelope
self._replace_all(list(existing.values()))

View file

@ -1,48 +1,18 @@
from __future__ import annotations
import enum
import functools
import math
import threading
from decimal import Decimal
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Generic, Iterable, TypeVar
from typing import Any, Callable, Generic, TypeVar
from .backing_store import BackingStore, CultCacheEnvelope
from .documents import DocumentDefinition, UnreadSlots, extract_value
from .stores import SchemaConflictError, parse_stored_at, retyped, stored_at_over, written_owns
from .documents import DocumentDefinition, extract_value
T = TypeVar("T")
F = TypeVar("F", bound=Callable[..., Any])
GLOBAL_KEY = "__global__"
class UpdateOutcome(enum.Enum):
UPDATED = "updated"
DECLINED = "declined"
LOST_RACE = "lost_race"
@dataclass(frozen=True)
class UpdateResult(Generic[T]):
"""What `CultCache.update` did and the cache's record at the key after it (None when it holds nothing). The document decides
nothing: it is a read after the outcome."""
outcome: UpdateOutcome
document: T | None
@dataclass(frozen=True)
class _Expected:
"""What an update's commit requires to still hold. A cache with no store requires the held envelope object at the key (None:
nothing held); a store-backed one requires the type's home to be `store` and the record there to have content version
`version` (None: no record)."""
held: CultCacheEnvelope | None = None
store: BackingStore | None = None
version: str | None = None
def _locked(method: F) -> F:
"""Runs a read holding the cache's lock. Reads are reentrant and may run inside a mutation."""
@ -81,52 +51,6 @@ class CultCacheError(RuntimeError):
pass
# Blank is no value: empty or only C# White_Space characters (the 25 chars string.IsNullOrWhiteSpace is true for).
# An explicit set, not str.isspace(): Python also counts U+001C-001F, which C# does not, and omits nothing C# counts.
_BLANK_INDEX_CHARS = frozenset(
chr(code)
for code in (
*range(0x09, 0x0E),
0x20,
0x85,
0xA0,
0x1680,
*range(0x2000, 0x200B),
0x2028,
0x2029,
0x202F,
0x205F,
0x3000,
)
)
def _utf16_order(text: str) -> bytes:
"""Sorts as C# StringComparer.Ordinal does: by UTF-16 code unit, not code point."""
return text.encode("utf-16-be", "surrogatepass")
def index_text(value: Any) -> str | None:
"""The canonical text of an index value, as C# IndexValue and RenderCanonicalNumber, or None when it names no
value: a string is as is (never trimmed), a boolean is `True`/`False`, an integer is its digits, a non-integral
finite float is its exact decimal expansion, NaN and infinities and blank strings are no value."""
if value is None:
return None
if isinstance(value, bool): # before int: bool is an int subclass
return "True" if value else "False"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
if not math.isfinite(value):
return None
if value.is_integer():
return str(int(value)) # -0.0 is 0
text = format(Decimal(value), "f") # Decimal(float) is exact
return text.rstrip("0").rstrip(".") if "." in text else text
text = value if isinstance(value, str) else str(value)
return None if all(char in _BLANK_INDEX_CHARS for char in text) else text
@dataclass
class _State:
documents: dict[str, DocumentDefinition[Any]] = field(default_factory=dict)
@ -147,24 +71,6 @@ class _State:
# 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)
# The records the last pull read that no registered type claims, sorted by key. Derived from the pull and swapped with the
# rest of the state, written nowhere else; it decides only refusals at its keys. A store keeps their bytes.
foreign: list["ForeignRecord"] = field(default_factory=list)
# The records held read-only, by key (a key holds one record) with the type that holds it: the payload carried slots past the
# declared ones and nothing consents to the loss. Derived from the load or the write that admitted the record and swapped with
# the rest of the state; it decides only refusals at its keys, and any other key is absent.
unread: dict[str, tuple[str, UnreadSlots]] = field(default_factory=dict)
@dataclass(frozen=True)
class ForeignRecord:
"""A record a store holds that no registered type claims: the cache does not serve it, refuses a write or a removal at its
key, and a store keeps its bytes. Registering a type that declares its schema id claims it at the next load."""
key: str
schema_id: str
schema_name: str
stored_at: str
@dataclass(frozen=True)
@ -232,28 +138,11 @@ class CultCache:
self._register_document_type(document)
def _register_document_type(self, document: DocumentDefinition[Any]) -> None:
# Every registration refusal is a SchemaConflictError naming the new document's schema id and both schema names.
entry = document.catalog_entry()
schema_name = entry.schema_name
def refuse(registered: DocumentDefinition[Any], what: str) -> SchemaConflictError:
return SchemaConflictError(
entry.schema_id,
[registered.catalog_entry().schema_name, schema_name],
"",
message=f"{what} is already registered to document type {registered.type!r} and cannot also be claimed by "
f"{document.type!r}.",
)
if document.type in self._state.documents:
raise refuse(self._state.documents[document.type], f"Document type {document.type!r}")
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 refuse(self._state.documents_by_schema_name[schema_name], f"Schema name {schema_name!r}")
# One document owns each schema id. Others may list it as compatible beside its owner: a record under the id resolves to
# its owner, and to a lister only when nothing owns it. Neither depends on the order documents were registered in.
for registered in self._state.documents.values():
if registered.catalog_entry().schema_id == entry.schema_id:
raise refuse(registered, f"Schema id {entry.schema_id!r}")
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:
@ -279,8 +168,6 @@ class CultCache:
@_mutating
def register_index(self, document: DocumentDefinition[Any], index: str, extractor: str | Any) -> None:
self._assert_registered(document)
if index in document.roles:
raise CultCacheError(f"Index '{index}' of {document.type} is declared as a role; a role is never a unique index.")
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)
@ -290,11 +177,7 @@ class CultCache:
) -> 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(
[(type, key, value) for type, values in self._state.values.items() for key, value in values.items()],
name_extractors,
index_extractors,
)
names, indexes, lookup_keys = self._derive_lookups(self._state.values, name_extractors, index_extractors)
(
self._state.name_extractors,
self._state.index_extractors,
@ -362,29 +245,19 @@ class CultCache:
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]] = {}
read_values: dict[tuple[str, str], Any] = {} # (type, key) -> value, one per key, the last read wins; the judge is order-free
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()
foreign: list[ForeignRecord] = []
loaded_unread: dict[str, tuple[str, UnreadSlots]] = {}
type_at_key: dict[str, tuple[str, int]] = {} # one record per key across every store: its type (or schema name) and store
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 position, store in enumerate(stores):
for store in stores:
for envelope in store.pull_all():
document = self._resolve_document_for_envelope(envelope)
if document is None:
entry = envelope.catalog_entry
held_name = entry.schema_name if entry is not None else envelope.type
self._claim_key(type_at_key, envelope.key, held_name, position)
foreign.append(
ForeignRecord(envelope.key, envelope.schema_id or envelope.type, held_name, envelope.stored_at)
)
continue
raise CultCacheError(f"Unknown persisted document type: {envelope.type}")
if envelope.type != document.type:
envelope = CultCacheEnvelope(
key=envelope.key,
@ -407,16 +280,11 @@ class CultCache:
if envelope.key != GLOBAL_KEY:
loaded_legacy_global_keys[envelope.type] = envelope.key
envelope = replace(envelope, key=GLOBAL_KEY)
self._claim_key(type_at_key, envelope.key, envelope.type, position)
arrived, envelope = envelope, self._stamped(document, envelope)
value, unread = self._decode_marked(document, arrived)
if unread is not None:
loaded_unread[envelope.key] = (envelope.type, unread)
value = document.decode_payload(envelope.payload)
loaded_values.setdefault(envelope.type, {})[envelope.key] = value
read_values[(envelope.type, envelope.key)] = value
loaded_envelopes.setdefault(envelope.type, {})[envelope.key] = envelope
loaded_names, loaded_indexes, loaded_lookup_keys = self._derive_lookups(
[(type, key, value) for (type, key), value in read_values.items()], self._state.name_extractors, self._state.index_extractors
loaded_values, self._state.name_extractors, self._state.index_extractors
)
# Plain attribute assignments: nothing fallible runs once the swap begins.
(
@ -426,30 +294,7 @@ class CultCache:
self._state.indexes,
self._state.lookup_keys,
self._state.legacy_global_keys,
self._state.foreign,
self._state.unread,
) = (
loaded_values,
loaded_envelopes,
loaded_names,
loaded_indexes,
loaded_lookup_keys,
loaded_legacy_global_keys,
sorted(foreign, key=lambda record: _utf16_order(record.key)),
loaded_unread,
)
@staticmethod
def _claim_key(type_at_key: dict[str, tuple[str, int]], key: str, type: str, store: int) -> None:
"""A key holds one record whatever its type, across every store a load reads. The refusal names the key and both types,
never a payload."""
held, held_store = type_at_key.setdefault(key, (type, store))
if held != type or held_store != store:
raise CultCacheError(f"Record {key!r} is held by both {held} and {type}: a key holds one record; the load admitted nothing.")
def foreign_records(self) -> list[ForeignRecord]:
"""The records the last load read that no registered type claims, sorted by key (UTF-16 code-unit order)."""
return list(self._state.foreign)
) = (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)
@ -509,7 +354,7 @@ class CultCache:
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, expected: _Expected | None = None) -> str:
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}")
@ -521,56 +366,26 @@ class CultCache:
schema_id=catalog_entry.schema_id,
catalog_entry=catalog_entry,
)
return self._write(document, [(envelope, value)], batch=False, expected=expected)
self._write(document, [(envelope, value)], batch=False)
@_mutating
def put_envelope(self, document: DocumentDefinition[T], envelope: CultCacheEnvelope) -> T:
self._assert_registered(document)
self._check_envelope(document, envelope)
arrived, envelope = envelope, self._stamped(document, envelope)
value, unread = self._decode_marked(document, envelope)
self._write(document, [(envelope, value)], batch=False, unread={envelope.key: unread})
value = document.decode_payload(envelope.payload)
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] = []
stamped: list[CultCacheEnvelope] = []
unread: dict[str, UnreadSlots | None] = {}
for envelope in envelopes:
self._check_envelope(document, envelope)
stamped.append(self._stamped(document, envelope))
value, unread[envelope.key] = self._decode_marked(document, stamped[-1])
values.append(value)
self._write(document, list(zip(stamped, values)), batch=True, unread=unread)
values.append(document.decode_payload(envelope.payload))
self._write(document, list(zip(envelopes, values)), batch=True)
return values
@staticmethod
def _decode_marked(document: DocumentDefinition[Any], held_as: CultCacheEnvelope) -> tuple[Any, UnreadSlots | None]:
"""The payload decoded under the document, with the mark of any slot it carried past the declared ones, or no mark when the
loss is consented. `held_as` is the envelope under the id the record is stored under: a load's arrival, a write's stamped
envelope, so a key is writable in memory exactly when it is after a reload. A depth-0 mark is consented only when that id is
one the document lists compatible and its catalog entry has slots past the document's that cover the mark's slot count (the
loss a compatible-id declaration consents to, cultlib-gaps:ruling:slot-mark-consent). Any other mark keeps the record read-only."""
value, slots = document.decode_payload_marked(held_as.payload)
if slots is None:
return value, None
entry, stored = document.catalog_entry(), held_as.catalog_entry
if slots.depth0_only and stored is not None and held_as.schema_id in entry.compatible_schema_ids and held_as.schema_id != entry.schema_id:
declared = {member.slot for member in entry.members}
reported = [member.slot for member in stored.members if member.slot not in declared]
if reported and slots.slot_count <= max(reported) + 1:
return value, None
return value, slots
@staticmethod
def _stamped(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> CultCacheEnvelope:
"""The record under its registered schema's id, whatever id the envelope arrived under, so the catalog entry a store
writes is the one its schema id names."""
entry = document.catalog_entry()
return replace(envelope, schema_id=entry.schema_id, catalog_entry=entry)
@staticmethod
def _check_envelope(document: DocumentDefinition[Any], envelope: CultCacheEnvelope) -> None:
if envelope.type != document.type:
@ -583,182 +398,39 @@ class CultCache:
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,
unread: dict[str, UnreadSlots | None] | None = None,
expected: _Expected | None = None,
) -> str:
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. With `expected` (an update's commit) the write
also requires the record to be unchanged since it was read and returns 'mismatch', having written and installed nothing,
when it is not; otherwise 'committed'. A record is held under the stored_at its store reports writing."""
touched; once the store accepts, applying to the cache has no fallible step."""
type = document.type
# A batch naming a key twice lands the last record for it, as C# CultCacheBatch stages one operation per key. The
# judge, the store and the apply all read this one list.
records = list({envelope.key: (envelope, value) for envelope, value in records}.values())
self._refuse_unread([envelope.key for envelope, _ in records])
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
]
displaced = self._refuse_retype(document, [envelope for envelope, _ in records])
self._refuse_index_sharing(
[(type, envelope.key, keys) for envelope, _, keys in admitted],
self._state.indexes,
{(type, envelope.key) for envelope, _ in records},
)
store = self._store_for_type(type)
legacy_key = self._state.legacy_global_keys.get(type)
stamps: dict[str, Any] = {}
if expected is not None:
if legacy_key is not None:
raise self._legacy_global_refusal(document, legacy_key)
if expected.store is None:
held = self._state.envelopes.get(type, {}).get(records[0][0].key)
if store is not None or held is not expected.held:
return "mismatch"
elif store is not expected.store:
return "mismatch"
else:
stamp = store.commit_if(records[0][0], expected.version) # type: ignore[attr-defined]
if stamp is None:
return "mismatch"
stamps[records[0][0].key] = stamp
elif store is not None:
if store is not None:
if batch:
reported = store.push_all([envelope for envelope, _ in records])
stamps = reported if isinstance(reported, dict) else {}
store.push_all([envelope for envelope, _ in records])
else:
stamps[records[0][0].key] = store.push(records[0][0])
store.push(records[0][0])
if legacy_key is not None:
self._retire_legacy_global(store, type, legacy_key)
else:
for envelope, _ in records:
held_at = next((held for held in (by_type.get(envelope.key) for by_type in self._state.envelopes.values()) if held is not None), None)
if held_at is not None:
stamps[envelope.key] = stored_at_over(envelope.stored_at, held_at.stored_at)
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 other, key in displaced:
self._drop(other, key)
for envelope, value, keys in admitted:
stamp = stamps.get(envelope.key)
if isinstance(stamp, str) and parse_stored_at(stamp) is not None:
envelope = replace(envelope, stored_at=stamp)
self._remove_lookups(type, envelope.key)
values[envelope.key] = value
envelopes[envelope.key] = envelope
# A held mark refused this write above, so a key reaching here carries none to clear.
if (mark := (unread or {}).get(envelope.key)) is not None:
self._state.unread[envelope.key] = (type, mark)
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
return "committed"
def _refuse_retype(self, document: DocumentDefinition[Any], written: list[CultCacheEnvelope]) -> list[tuple[str, str]]:
"""The one key-identity judge (variants:ruling:cache-refuses-retype): a key holds one record whatever its type, so a write
may not land at a key this cache holds another type's record at, unless the written type owns that record's schema id
(the single-file store's commit guard asks the same question of the file), and may not land at a key a store carries
foreign. Returns the (type, key) records an owning write replaces; refuses, changing nothing, otherwise."""
replaced: list[tuple[str, str]] = []
foreign = {record.key: record for record in self._state.foreign}
for envelope in written:
carried = foreign.get(envelope.key)
if carried is not None:
raise self._foreign_refusal(carried)
for other, held_envelopes in self._state.envelopes.items():
held = held_envelopes.get(envelope.key)
if other == document.type or held is None:
continue
if not written_owns(held, envelope) or self._store_for_type(other) is not self._store_for_type(document.type):
raise retyped(held, envelope)
replaced.append((other, envelope.key))
return replaced
@_locked
def is_writable(self, document: DocumentDefinition[Any], key: str) -> bool:
"""Whether a write or removal of the key by this document would pass the judges that run before any store is touched: False for
a record held read-only (its payload carried slots past the declared ones, at any depth, and nothing consents to the loss, so
writing it would drop data a newer writer stored), for a key a store carries foreign, and for a key held as another type's
record this document does not own. Advisory (the write still decides): it answers at call time, and a key nothing holds is
writable. It does not look at store paths: a link at a store path is not predicted, and the write refuses it with
`StoreLinkedError`."""
self._assert_registered(document)
try:
self._refuse_at(document, key)
except (SchemaConflictError, CultCacheError):
return False
return True
def _refuse_at(self, document: DocumentDefinition[Any], key: str) -> list[tuple[str, str]]:
"""The judges a write or removal of the key by this document passes before any store is touched, asked by `is_writable` and
by `delete`: a global document writes only its global key, a record held read-only is never replaced, and a key held as
another type's record or carried foreign is refused (`_refuse_retype`)."""
if document.global_document and key != GLOBAL_KEY:
raise CultCacheError(f"Global document {document.type} must use key {GLOBAL_KEY}")
self._refuse_unread([key])
entry = document.catalog_entry()
probe = CultCacheEnvelope.create(key=key, type=document.type, payload=b"", schema_id=entry.schema_id, catalog_entry=entry)
return self._refuse_retype(document, [probe])
def _refuse_unread(self, keys: list[str]) -> None:
"""The one check every write and removal asks before any store is touched."""
for key in keys:
held = self._state.unread.get(key)
if held is None:
continue
type, slots = held
raise self._read_only_refusal(self._state.documents[type], key, self._state.envelopes[type][key].schema_id, slots)
@staticmethod
def _read_only_refusal(
document: DocumentDefinition[Any], key: str, schema_id: str | None, slots: UnreadSlots
) -> SchemaConflictError:
"""The one refusal of a write to a record that carries slots this build would drop: a record the cache holds read-only,
or one an update reads from the store."""
name = document.catalog_entry().schema_name
return SchemaConflictError(
schema_id or document.type,
[name],
key,
message=(
f"{name} record {key} is read-only: {slots.type_name} was read with "
f"{slots.slot_count} slots where this build declares {slots.declared_count} ({slots.occurrences} object(s) in the "
"record). Writing it would drop data a newer writer stored; read it with types that declare those slots, or "
"write it from that build."
),
unread_slots=slots,
)
@staticmethod
def _legacy_global_refusal(document: DocumentDefinition[Any], legacy_key: str) -> CultCacheError:
return CultCacheError(
f"Global {document.type} is held under the legacy key {legacy_key!r}; update cannot copy it. "
"put_global writes the global and retires the legacy key."
)
@staticmethod
def _foreign_refusal(record: ForeignRecord) -> SchemaConflictError:
return SchemaConflictError(
record.schema_id,
[record.schema_name],
record.key,
message=(
f"Record {record.key!r} is held by the store as a {record.schema_name} (schema id {record.schema_id!r}) that no "
"registered type claims; a write or removal there would replace what this cache cannot read. Register the type "
"that owns the schema id, or remove the record with the runtime that wrote it."
),
)
@staticmethod
def _retire_legacy_global(store: BackingStore, type: str, legacy_key: str) -> None:
@ -778,81 +450,20 @@ class CultCache:
self._assert_global(document)
self._put(document, GLOBAL_KEY, value)
def update(
self, document: DocumentDefinition[T], key: str, apply: Callable[[T | None], T | None], *, max_attempts: int = 5
) -> UpdateResult[T]:
"""Reads the record at the key as its home store holds it, applies `apply` to a fresh copy of it, and commits the result
only if the record is still the one that was read; when another writer got there first it reads again and runs `apply`
again, up to `max_attempts` times.
@_mutating
def update(self, document: DocumentDefinition[T], key: str, updater: Any) -> T:
return self._update(document, key, updater)
`apply` may run up to `max_attempts` times, on a fresh copy of the record as the store holds it now (None when the key
holds nothing), which it may change and return. It must not write the same key through this cache or have side effects.
Returning None writes nothing (DECLINED). A lost race that outlasts `max_attempts` returns LOST_RACE and does not raise;
a refusal, or a raise from `apply`, propagates and is not retried. The write is durable when update returns. The
result's document is the cache's record after the outcome. `put` is the blind write. A cache with no store copies its
held instance; a store without `read` and `commit_if` (the JSON lines store among them) refuses before `apply` runs."""
if not callable(apply):
raise TypeError("CultCache update needs a function to apply.")
if not isinstance(max_attempts, int) or isinstance(max_attempts, bool):
raise TypeError(f"CultCache update max_attempts must be an int, not {type(max_attempts).__name__}.")
if max_attempts < 1:
raise ValueError(f"CultCache update max_attempts must be at least 1, not {max_attempts}.")
for attempt in range(1, max_attempts + 1):
copy, expected = self._read_for_update(document, key)
# Outside the cache's lock and the store's: apply may take its time and may write other keys through this cache.
next_value = apply(copy)
if next_value is None:
return UpdateResult(UpdateOutcome.DECLINED, self.get(document, key))
if self._commit_update(document, key, next_value, expected) == "committed":
return UpdateResult(UpdateOutcome.UPDATED, self.get(document, key))
# Display only: the cache shows what the winner left.
if self._state.stores_by_type or self._state.generic_store is not None:
self.pull_all_backing_stores()
return UpdateResult(UpdateOutcome.LOST_RACE, self.get(document, key))
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)
return updated
@_mutating
def _read_for_update(self, document: DocumentDefinition[T], key: str) -> tuple[T | None, _Expected]:
"""One attempt's read: the copy to hand `apply` and the condition its commit holds. Every refusal that does not depend on
what `apply` returns is raised here, so `apply` never runs for a write that cannot land. Installs nothing."""
self._assert_registered(document)
self._refuse_at(document, key)
legacy_key = self._state.legacy_global_keys.get(document.type)
if legacy_key is not None:
raise self._legacy_global_refusal(document, legacy_key)
store = self._store_for_type(document.type)
if store is None:
held = self._state.envelopes.get(document.type, {}).get(key)
return (None if held is None else document.decode_payload(held.payload)), _Expected(held=held)
read, commit_if = getattr(store, "read", None), getattr(store, "commit_if", None)
if read is None or commit_if is None:
raise CultCacheError(
f"Backing store {type(store).__name__} for {document.type} cannot write conditionally; "
"update needs a store with read and commit_if."
)
if document.global_document:
for envelope in store.pull_all():
if envelope.key != GLOBAL_KEY and self._resolve_document_for_envelope(envelope) is document:
raise self._legacy_global_refusal(document, envelope.key)
found = read(key)
if found is None:
return None, _Expected(store=store)
envelope, version = found
if self._resolve_document_for_envelope(envelope) is not document:
raise CultCacheError(f"Record {key} is not a {document.type}; update cannot copy it.")
value, unread = self._decode_marked(document, envelope)
if unread is not None:
raise self._read_only_refusal(document, key, envelope.schema_id, unread)
return value, _Expected(store=store, version=version)
@_mutating
def _commit_update(self, document: DocumentDefinition[T], key: str, value: T, expected: _Expected) -> str:
return self._put(document, key, value, expected)
def update_global(
self, document: DocumentDefinition[T], apply: Callable[[T | None], T | None], *, max_attempts: int = 5
) -> UpdateResult[T]:
def update_global(self, document: DocumentDefinition[T], updater: Any) -> T:
self._assert_global(document)
return self.update(document, GLOBAL_KEY, apply, max_attempts=max_attempts)
return self._update(document, GLOBAL_KEY, updater)
@_mutating
def delete(self, document: DocumentDefinition[Any], key: str) -> None:
@ -860,29 +471,19 @@ class CultCache:
def _delete(self, document: DocumentDefinition[Any], key: str) -> None:
self._assert_registered(document)
# A key held as another type's record this document owns is removed, as a write of the key would replace it (C# Remove is by key).
owned = self._refuse_at(document, key)
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:
# One record per key: the owned record when this document does not hold the key, else its own, in one store call.
holder = owned[0][0] if owned else document.type
store.delete(holder, key if legacy_key is None else legacy_key)
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]
for other, _ in owned:
self._drop(other, key)
self._drop(document.type, key)
def _drop(self, type: str, key: str) -> None:
"""Removes the held record and its lookups from the cache's maps; the store is the caller's."""
self._remove_lookups(type, key)
self._remove_lookups(document.type, key)
for by_type in (self._state.values, self._state.envelopes, self._state.lookup_keys):
held = by_type.get(type)
held = by_type.get(document.type)
if held is not None:
held.pop(key, None)
if not held:
by_type.pop(type, None)
by_type.pop(document.type, None)
@_mutating
def delete_global(self, document: DocumentDefinition[Any]) -> None:
@ -922,76 +523,36 @@ class CultCache:
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 = index_text(extract_value(value, extractor))
index_value = extract_value(value, extractor)
if index_value is not None:
indexes[index] = index_value
indexes[index] = str(index_value)
return _LookupKeys(None if name is None else str(name), indexes)
def _derive_lookups(
self,
records: list[tuple[str, str, Any]],
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 (type, key, value) records, judged whole and order-free, in fresh dicts, touching no cache state."""
"""Builds name and index lookups for the given values in fresh dicts, touching no cache state."""
names: _Names = {}
indexes: _Indexes = {}
lookup_keys: _LookupKeysByType = {}
landing: list[tuple[str, str, _LookupKeys]] = []
for type, key, value in records:
keys = self._lookup_keys_for(value, name_extractors.get(type), index_extractors.get(type, {}))
lookup_keys.setdefault(type, {})[key] = keys
landing.append((type, key, keys))
# Judged whole and order-free, over every type at once and on the file alone (never against what the cache held): a held value can
# have been mutated in place since it was admitted, and another process may have moved a value between records.
self._refuse_index_sharing(landing, {}, ())
for type in lookup_keys:
for key, keys in lookup_keys.get(type, {}).items():
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():
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 _refuse_index_sharing(
self,
landing: list[tuple[str, str, _LookupKeys]],
held: dict[tuple[str, str, str], str],
replaced: Iterable[tuple[str, str]],
) -> None:
"""The one index rule (variants:ruling:unique-indexes): within one type and one unique alias, no two records hold one
non-blank value, judged on the state the admission would leave and never on staging, file or pull order. The holders
of a value are the landing keys projecting it plus the held key unless this admission replaces it. Of every
(type, alias, value) with two or more distinct holders, the refusal names the least by (alias, its least key, its
second-least key, schema name), each compared by UTF-16 code unit, never the value."""
released = set(replaced)
landed: dict[tuple[str, str, str], list[str]] = {}
for type, key, keys in landing:
for index, index_value in keys.indexes.items():
landed.setdefault((type, index, index_value), []).append(key)
violations: list[tuple[bytes, bytes, bytes, bytes, str, str, str, str]] = []
for (type, index, index_value), keys in landed.items():
held_key = held.get((type, index, index_value))
if held_key is not None and (type, held_key) not in released:
keys = [*keys, held_key]
distinct = sorted(keys, key=_utf16_order)
if len(distinct) > 1:
document = self._state.documents.get(type)
schema_name = (document.schema_name if document is not None else None) or type
violations.append(
(_utf16_order(index), _utf16_order(distinct[0]), _utf16_order(distinct[1]), _utf16_order(schema_name),
index, distinct[0], distinct[1], schema_name)
)
if violations:
*_, index, first, second, schema_name = min(violations, key=lambda violation: violation[:4])
raise CultCacheError(
f"Records {first} and {second} ({schema_name}) both hold unique index '{index}'; "
"an index value names one record."
)
def _remove_lookups(self, type: str, key: str) -> None:
"""Removes the lookups the held record produced at admission, handing a name to another held
record that produced it too (an index value has one holder). Reads stored lookup keys only; runs no extractor."""
"""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:
@ -1006,6 +567,9 @@ class CultCache:
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:
@ -1017,27 +581,6 @@ class CultCache:
raise CultCacheError(f"Document type is not global: {document.type}")
def _resolve_document_for_envelope(self, envelope: CultCacheEnvelope) -> DocumentDefinition[Any] | None:
"""A record resolves by its schema id: the document that owns the id, else the one document that lists it as compatible.
Several listers and no owner name no single document, and the record is refused. The name its catalog carries is
metadata; it names a document only when the id names none (a store from a runtime whose schema ids this one cannot
know)."""
if envelope.schema_id:
entries = [(document, document.catalog_entry()) for document in self._state.documents.values()]
for document, entry in entries:
if entry.schema_id == envelope.schema_id:
return document
listers = [document for document, entry in entries if envelope.schema_id in entry.compatible_schema_ids]
if len(listers) > 1:
raise SchemaConflictError(
envelope.schema_id,
sorted(document.catalog_entry().schema_name for document in listers),
envelope.key,
message=f"Schema id {envelope.schema_id!r} of record {envelope.key!r} is owned by no registered document and "
f"declared compatible by several ({', '.join(sorted(document.type for document in listers))}), so it resolves "
"to none. Register the document that owns the id, or declare it on one document.",
)
if listers:
return listers[0]
document = self._state.documents.get(envelope.type)
if document is not None:
return document

View file

@ -1,15 +1,11 @@
from __future__ import annotations
import json
import threading
from dataclasses import MISSING, dataclass, field
from collections.abc import Mapping, Sequence, Set
from itertools import chain
from typing import Any, Callable, Generic, Iterable, TypeVar
import msgpack
from msgpack.exceptions import StackError as _MsgpackStackError
from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember
from .leaves import Float32, SlotCodec
from .leaves import any as _any_leaf
@ -28,50 +24,12 @@ or skipped, the outermost one is depth 1; depth 256 is accepted and 257 refused,
MAX_SLOT_KEY = 65535
"""The largest slot key a record may define: the largest MessagePack array16 length."""
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
try:
from msgpack.exceptions import StackError as _MsgpackStackError
@dataclass(frozen=True)
class UnreadSlots:
"""What a payload decode found past the slots this runtime declares, at any depth: the first type read with extra slots, how
many slots it carried against how many it declares, and how many objects in the payload did. It holds no slot value, so a
refusal can name it. `depth0_only` is True when every mark sat in the document's own record (the half of consent the payload
decides). A record carrying it is read-only unless the schema comparison explains every mark (see `CultCache.is_writable`)."""
type_name: str
slot_count: int
declared_count: int
occurrences: int
depth0_only: bool
class _SlotScope:
"""Opened by `DocumentDefinition.decode_payload_marked`; the record codec and the union codec note into it. It is thread-local,
so no codec signature carries it, and a codec decoded outside a payload decode notes nothing."""
def __init__(self) -> None:
self.depth = 0 # records entered: 0 while the document's own record is read
self.first: tuple[str, int, int] | None = None
self.occurrences = 0
self.depth0_only = True
def note(self, type_name: str, slot_count: int, declared_count: int, depth0: bool) -> None:
if self.first is None:
self.first = (type_name, slot_count, declared_count)
self.occurrences += 1
self.depth0_only = self.depth0_only and depth0
def result(self) -> UnreadSlots | None:
if self.first is None:
return None
return UnreadSlots(*self.first, self.occurrences, self.depth0_only)
_scope = threading.local()
def _current_scope() -> _SlotScope | None:
return getattr(_scope, "open", None)
_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,)
except ModuleNotFoundError:
_STACK_FAULTS = ()
_CONTAINERS = (Mapping, Sequence, Set)
@ -127,10 +85,22 @@ def _json_payload_decoder(payload: bytes) -> Any:
return json.loads(payload.decode("utf-8"))
def _msgpack() -> Any:
try:
import msgpack # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"MessagePack payload encoding requires the optional 'msgpack' dependency. "
"Install with: python -m pip install cultcache-py[msgpack]"
) from exc
return msgpack
def _pack_tree(tree: Any) -> bytes:
"""The bytes of the tree the leaves produced. `packb` writes everything but a `Float32`, which it refuses with a
TypeError; only then does an iterative walk pack each array and map header and each value, a `Float32` through a
single-float packer."""
msgpack = _msgpack()
try:
return msgpack.packb(tree, use_bin_type=True) # type: ignore[no-any-return]
except TypeError:
@ -155,27 +125,14 @@ def _pack_tree(tree: Any) -> bytes:
def _messagepack_payload_decoder(payload: bytes) -> Any:
return msgpack.unpackb(payload, raw=False)
def read_payload_tree(type_name: str, payload: bytes, decoder: Callable[[bytes], Any] | None = None) -> Any:
"""The one read of a payload's tree: exactly one MessagePack value, within the nesting limit. Depth is judged first,
on the value read so far, so a tail never pre-empts it (C# throws at container 257 before it reaches the tail); then a
tail is refused by count and never echoed. Raised outside the handler so no `__context__` keeps the tail."""
after = -1
unpacked: Any = None
try:
tree = (decoder or _messagepack_payload_decoder)(payload)
except _STACK_FAULTS:
raise CultCachePayloadDepthError(type_name, "read") from None
except msgpack.ExtraData as error:
after = len(error.extra)
unpacked = error.unpacked
if after >= 0:
_refuse_past_depth_limit(unpacked, type_name, "read")
raise ValueError(f"{type_name} payload has {after} bytes after its first MessagePack value (refused on read).")
_refuse_past_depth_limit(tree, type_name, "read")
return tree
import msgpack # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"MessagePack payload decoding requires the optional 'msgpack' dependency. "
"Install with: python -m pip install cultcache-py[msgpack]"
) from exc
return msgpack.unpackb(payload, raw=False)
@dataclass(frozen=True)
@ -221,11 +178,6 @@ class DocumentDefinition(Generic[T]):
global_document: bool = False
name: str | Extractor[T] | None = None
indexes: dict[str, str | Extractor[T]] = field(default_factory=dict)
"""Unique lookups (C# `[CultIndex]`): no two records of the type hold one non-blank value, judged at every commit and
load. `get_by_index` answers the one holder."""
roles: dict[str, str | Extractor[T]] = field(default_factory=dict)
"""Selection aliases (C# `[CultRole]`): many records may share a value. Never indexed for lookup, never refuse; the
member's catalog `index_alias` is the same either way."""
payload_encoder: Callable[[Any], bytes] = _json_payload_encoder
payload_decoder: Callable[[bytes], Any] = _json_payload_decoder
schema_id: str | None = None
@ -249,29 +201,15 @@ class DocumentDefinition(Generic[T]):
return self.payload_encoder(tree)
def decode_payload(self, payload: bytes) -> T:
return self.decode_payload_marked(payload)[0]
def decode_payload_marked(self, payload: bytes) -> tuple[T, UnreadSlots | None]:
"""The value and what the decode found past the declared slots, at any depth. The payload depth check runs first: a
payload past the limit is refused, never marked."""
tree = read_payload_tree(self.type, payload, self.payload_decoder)
outer, opened = _current_scope(), _SlotScope()
_scope.open = opened
try:
value = self.decode(tree)
finally:
_scope.open = outer
tree = self.payload_decoder(payload)
except _STACK_FAULTS:
raise CultCachePayloadDepthError(self.type, "read") from None
_refuse_past_depth_limit(tree, self.type, "read")
value = self.decode(tree)
if self.validate:
self.validate(value)
return value, opened.result()
def __post_init__(self) -> None:
# Judged here so every constructor passes it: the factories, the dataclass constructor and dataclasses.replace.
for alias in self.roles:
if alias in self.indexes:
raise ValueError(
f"Document type {self.type} declares '{alias}' as both an index and a role; an index already names its role."
)
return value
def catalog_entry(self) -> CultCacheSchemaCatalogEntry:
if self._catalog_entry is not None:
@ -324,7 +262,6 @@ def define_document_type(
global_document: bool = False,
name: str | Extractor[T] | None = None,
indexes: dict[str, str | Extractor[T]] | None = None,
roles: dict[str, str | Extractor[T]] | None = None,
payload_encoder: Callable[[Any], bytes] = _json_payload_encoder,
payload_decoder: Callable[[bytes], Any] = _json_payload_decoder,
schema_id: str | None = None,
@ -343,7 +280,6 @@ def define_document_type(
global_document=global_document,
name=name,
indexes=indexes or {},
roles=roles or {},
payload_encoder=payload_encoder,
payload_decoder=payload_decoder,
schema_id=schema_id,
@ -365,7 +301,6 @@ def define_database_entry_type(
global_document: bool = False,
name: str | Extractor[T] | None = None,
indexes: dict[str, str | Extractor[T]] | None = None,
roles: dict[str, str | Extractor[T]] | None = None,
schema_id: str | None = None,
schema_name: str | None = None,
schema_version: str | None = None,
@ -382,7 +317,7 @@ def define_database_entry_type(
normalized = _normalize_fields(type, fields)
member_name_lookup = name if isinstance(name, str) else None
index_lookup = {**(roles or {}), **(indexes or {})}
index_lookup = indexes or {}
def members() -> tuple[CultCacheSchemaCatalogMember, ...]:
return tuple(
@ -409,7 +344,6 @@ def define_database_entry_type(
global_document=global_document,
name=name,
indexes=indexes,
roles=roles,
payload_encoder=_pack_tree,
payload_decoder=_messagepack_payload_decoder,
schema_id=schema_id,
@ -488,9 +422,6 @@ def define_union(
raise ValueError(f"Union {name!r} value is malformed: expected [armKey, [armSlots]]")
arm_name, codec = by_key[key]
if codec is None:
scope = _current_scope()
if scope is not None and raw[1]:
scope.note(arm_name, len(raw[1]), 0, False)
return UnionArm(arm_name, None)
try:
return UnionArm(arm_name, codec.decode(raw[1]))
@ -552,26 +483,17 @@ def _record_codec(
def decode(raw: Any) -> Any:
if not isinstance(raw, list):
raise ValueError(f"DatabaseEntry document type {type!r} expected a MessagePack array")
scope = _current_scope()
if scope is not None and len(raw) > max_key + 1:
scope.note(type, len(raw), max_key + 1, scope.depth == 0)
values: dict[str, Any] = {}
if scope is not None:
scope.depth += 1
try:
for field in normalized:
if field.key < len(raw):
try:
values[field.name] = field.codec.decode(raw[field.key])
except ValueError as error:
raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None
elif field.default is MISSING:
values[field.name] = None
else:
values[field.name] = field.default() if callable(field.default) else field.default
finally:
if scope is not None:
scope.depth -= 1
for field in normalized:
if field.key < len(raw):
try:
values[field.name] = field.codec.decode(raw[field.key])
except ValueError as error:
raise ValueError(f"{type} slot {field.key} ({field.name}): {error}") from None
elif field.default is MISSING:
values[field.name] = None
else:
values[field.name] = field.default() if callable(field.default) else field.default
if cls is None:
return values
return cls(**values)

View file

@ -16,7 +16,10 @@ from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Callable, NoReturn
from msgpack import ExtType as _ExtType
try:
from msgpack import ExtType as _ExtType
except ModuleNotFoundError:
_ExtType = () # type: ignore[assignment,misc]
_bool, _bytes, _list, _int, _float, _str = bool, bytes, list, int, float, str
_BYTE_LIKE = (_bytes, bytearray, memoryview)

View file

@ -1,23 +1,12 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import sys
import threading
import time
import uuid
from contextlib import contextmanager
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable, Iterator, TypeVar
from typing import Any, Callable, TypeVar
import msgpack
from .documents import UnreadSlots
from .backing_store import (
CultCacheEnvelope,
CultCacheSchemaCatalogEntry,
@ -25,72 +14,21 @@ from .backing_store import (
)
STORE_FORMAT_VERSION = "cultcache.store.v1"
# A store that can hold element ids. A reader older than element ids refuses this header: it would skip the id slots of a
# nested element and rewrite the element without them. This runtime writes it back only for a store it read under it; it
# does not decide when a document carries ids.
STORE_FORMAT_ELEMENT_IDS = "cultcache.store.v3"
_READABLE_STORE_FORMATS = (STORE_FORMAT_VERSION, STORE_FORMAT_ELEMENT_IDS)
_STORE_FORMAT_PREFIX = "cultcache.store."
_PERSISTED_RECORD_SLOTS = 4
_LOCK_POLL_SECONDS = 0.01
LOCK_TIMEOUT_SECONDS = 30.0
_Contents = TypeVar("_Contents")
class StoreUnreadableError(ValueError):
"""A store file this runtime cannot read: not exactly one complete store (truncated, bytes after it, a missing or extra
slot), a header or record it does not know, or a body it cannot decode. Open, push, delete and push_all refuse a file
with this error, and a refused file is left as it was. The cause is `__cause__`."""
def __init__(self, path: Path, cause: BaseException) -> None:
super().__init__(f"CultCache store {path} is not readable: {cause}")
self.path = path
class SchemaConflictError(ValueError):
"""A write the catalog cannot describe: records of different types under one schema id, two arrived schemas that share an id
and disagree on the schema name, or a record no chosen entry publishes. Nothing is written. It names the id, the names
involved (`schema_names`) and a record key (`record_key`). A cache raises it too, with an empty record key, when a
document registers a schema id another registered document already carries. `unread_slots` is None except for the refusal to
write or remove a record held read-only because its payload carries slots past the declared ones; its message names the type
and both slot counts and never holds a slot value."""
def __init__(
self,
schema_id: str,
schema_names: list[str],
record_key: str,
*,
message: str | None = None,
unread_slots: UnreadSlots | None = None,
) -> None:
super().__init__(
message
or f"Schema id {schema_id!r} cannot describe record {record_key!r}: {', '.join(repr(name) for name in schema_names)}"
)
self.schema_id = schema_id
self.schema_names = schema_names
self.record_key = record_key
self.unread_slots = unread_slots
class JsonLinesBackingStore:
def __init__(self, path: str | os.PathLike[str]) -> None:
self.path = Path(path)
self._lock = threading.RLock()
def pull_all(self) -> list[CultCacheEnvelope]:
return self._pull() or []
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
return self._pull()
def _pull(self) -> list[CultCacheEnvelope] | None:
"""The envelopes the reader reads, or None when nothing is at the path."""
with self._lock:
text = _read_store(self.path, lambda path: path.read_text(encoding="utf-8"))
if text is None:
return None
return []
envelopes: list[CultCacheEnvelope] = []
for line_number, line in enumerate(text.splitlines(), start=1):
if not line.strip():
@ -111,41 +49,27 @@ class JsonLinesBackingStore:
raise ValueError(f"Malformed CultCache JSONL envelope at {self.path}:{line_number}: missing {exc}") from exc
return envelopes
def push(self, envelope: CultCacheEnvelope) -> str | None:
return self.push_all([envelope])[envelope.key]
def push(self, envelope: CultCacheEnvelope) -> None:
with self._lock:
existing = {(item.type, item.key): item for item in self.pull_all()}
existing[(envelope.type, envelope.key)] = envelope
self._replace_all(list(existing.values()))
def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> dict[str, str]:
"""Merges the envelopes, each stored later than any record it replaces, and returns the stamps written by key
({} when a soft push skipped)."""
stamps: dict[str, str] = {}
def merge(existing: dict[tuple[str, str], CultCacheEnvelope]) -> None:
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None:
with self._lock:
existing = {(item.type, item.key): item for item in self.pull_all()}
for envelope in envelopes:
replaced = existing.get((envelope.type, envelope.key))
if replaced is not None:
envelope = replace(envelope, stored_at=stored_at_over(envelope.stored_at, replaced.stored_at))
existing[(envelope.type, envelope.key)] = envelope
stamps[envelope.key] = envelope.stored_at
self._rewrite(merge, soft)
return stamps
self._replace_all(list(existing.values()))
def delete(self, type: str, key: str) -> None:
self._rewrite(lambda existing: existing.pop((type, key), None))
def _rewrite(self, change: Callable[[dict[tuple[str, str], CultCacheEnvelope]], object], soft: bool = False) -> None:
"""The one entry of every write: refuses a link at the path, then reads the file (a soft write stops here when the
reader read it), applies the change and replaces it."""
with self._lock:
_refuse_linked(self.path)
arrived = self._pull()
if soft and arrived is not None:
return
existing = {(item.type, item.key): item for item in arrived or []}
change(existing)
self._replace_all(list(existing.values()))
existing = [item for item in self.pull_all() if not (item.type == type and item.key == key)]
self._replace_all(existing)
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix(self.path.suffix + ".tmp")
lines = []
for envelope in sorted(envelopes, key=lambda item: (item.type, item.key)):
lines.append(json.dumps({
@ -154,425 +78,81 @@ class JsonLinesBackingStore:
"payload": base64.b64encode(envelope.payload).decode("ascii"),
"stored_at": envelope.stored_at,
}, ensure_ascii=False, sort_keys=True))
commit_store_file(self.path, ("\n".join(lines) + ("\n" if lines else "")).encode("utf-8"))
temp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
temp.replace(self.path)
class SingleFileMessagePackBackingStore:
def __init__(self, path: str | os.PathLike[str], lock_timeout: float = LOCK_TIMEOUT_SECONDS) -> None:
def __init__(self, path: str | os.PathLike[str]) -> None:
self.path = Path(path)
self.lock_timeout = lock_timeout
self._lock = threading.RLock()
# The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker.
self._format = STORE_FORMAT_VERSION
def pull_all(self) -> list[CultCacheEnvelope]:
return self._pull() or []
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
return self._pull()
def _pull(self) -> list[CultCacheEnvelope] | None:
"""The envelopes the reader reads, or None when nothing is at the path."""
msgpack = self._msgpack()
with self._lock:
# An empty file is not a store (no writer leaves one), so it is decoded and refused.
# The disk decides the header: a file that is gone is not marked.
self._format = STORE_FORMAT_VERSION
data = _read_store(self.path, Path.read_bytes)
if data is None:
return None
try:
decoded = msgpack.unpackb(data, raw=False)
snapshot = _decode_snapshot(decoded)
if snapshot is not None:
self._format, envelopes = snapshot
return envelopes
return _decode_legacy_envelopes(decoded)
except Exception as exc:
raise StoreUnreadableError(self.path, exc) from exc
return []
decoded = msgpack.unpackb(data, raw=False)
snapshot = _decode_v1_snapshot(decoded)
if snapshot is not None:
return snapshot
return _decode_legacy_envelopes(decoded, msgpack)
def push(self, envelope: CultCacheEnvelope) -> str | None:
with self._write_transaction():
return self._replace_all([envelope], self.pull_all())[envelope.key]
def push(self, envelope: CultCacheEnvelope) -> None:
with self._lock:
existing = {(item.type, item.key): item for item in self.pull_all()}
existing[(envelope.type, envelope.key)] = envelope
self._replace_all(list(existing.values()))
def push_all(self, envelopes: list[CultCacheEnvelope], soft: bool = False) -> dict[str, str]:
"""A soft push writes only where nothing is at the path: a store the reader reads is left byte for byte, and a file it
cannot read is refused by the same read, as in TS and Rust. Returns the stamps written by key ({} when skipped)."""
with self._write_transaction():
arrived = self._pull()
if soft and arrived is not None:
return {}
return self._replace_all(list(envelopes), arrived or [])
def push_all(self, envelopes: list[CultCacheEnvelope]) -> None:
with self._lock:
existing = {(item.type, item.key): item for item in self.pull_all()}
for envelope in envelopes:
existing[(envelope.type, envelope.key)] = envelope
self._replace_all(list(existing.values()))
def delete(self, type: str, key: str) -> None:
with self._write_transaction():
self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)])
def read(self, key: str) -> tuple[CultCacheEnvelope, str] | None:
"""The record at the key, of any type, and its content version, from one read; None when the key holds nothing."""
for envelope in self._pull() or []:
if envelope.key == key:
return envelope, record_version(envelope)
return None
def commit_if(self, envelope: CultCacheEnvelope, expected: str | None) -> str | None:
"""Writes the envelope only if the record at its key (any type) is still the one whose version is `expected`
(None: no record), judged under the write lock; returns the stored_at written, or None with nothing written."""
with self._write_transaction():
arrived = self._pull() or []
current = next((item for item in arrived if item.key == envelope.key), None)
if (None if current is None else record_version(current)) != expected:
return None
return self._replace_all([envelope], arrived)[envelope.key]
@contextmanager
def _write_transaction(self) -> Iterator[None]:
"""The thread lock, then the cross-process lock on `<path>.lock`, held across the read and the replace so two
processes never rewrite from the same stale read."""
with self._lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
with store_write_lock(self.path, self.lock_timeout):
yield
existing = [item for item in self.pull_all() if not (item.type == type and item.key == key)]
self._replace_all(existing)
def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> dict[str, str]:
snapshot, stamps = _encode_snapshot(supplied, arrived, self._format)
commit_store_file(self.path, msgpack.packb(snapshot, use_bin_type=True))
return stamps
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
msgpack = self._msgpack()
self.path.parent.mkdir(parents=True, exist_ok=True)
temp = self.path.with_suffix(self.path.suffix + ".tmp")
temp.write_bytes(msgpack.packb(_encode_v1_snapshot(envelopes), use_bin_type=True))
temp.replace(self.path)
@staticmethod
def _msgpack() -> Any:
try:
import msgpack # type: ignore
except ModuleNotFoundError as exc:
raise RuntimeError(
"SingleFileMessagePackBackingStore requires the optional 'msgpack' dependency. "
"Install with: python -m pip install cultcache-py[msgpack]"
) from exc
return msgpack
def canonical(candidates: Any) -> list[CultCacheSchemaCatalogEntry]:
"""Entries that tie, in one fixed order, so the catalog does not depend on the order they arrive in."""
return sorted(candidates, key=lambda entry: (entry.schema_name, entry.content_hash, ",".join(entry.compatible_schema_ids)))
def _derive_catalog(records: list[tuple[CultCacheEnvelope, bool]]) -> list[CultCacheSchemaCatalogEntry]:
"""The catalog a write leaves, derived from the records being written and nothing else. For each schema id a record carries,
one entry that publishes it: the entry that owns the id (a registered one, else one that arrived with the records), else one
that lists it as compatible (registered first). Entries are written as chosen, never merged; a registered entry wins over an
arrived one with the same own id. Records of different types under one id, two arrived entries with the same own id and
different schema names, or a record no chosen entry publishes, refuse the write. `records` are (envelope, registered): an
envelope the caller supplied is registered, one read back from the file is arrived."""
entries: list[tuple[CultCacheSchemaCatalogEntry, bool]] = []
by_id: dict[str, list[CultCacheEnvelope]] = {}
tiers: dict[str, list[tuple[CultCacheEnvelope, bool, CultCacheSchemaCatalogEntry]]] = {}
for envelope, registered in records:
def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]:
catalog_by_schema_id: dict[str, CultCacheSchemaCatalogEntry] = {}
for envelope in envelopes:
schema_id = _schema_id_for(envelope)
supplied = envelope.catalog_entry
publishes = supplied is not None and (supplied.schema_id == schema_id or schema_id in supplied.compatible_schema_ids)
entry = supplied if publishes else _default_catalog_entry(envelope)
entries.append((entry, registered))
by_id.setdefault(schema_id, []).append(envelope)
tiers.setdefault(schema_id, []).append((envelope, registered, entry))
if schema_id not in catalog_by_schema_id:
catalog_by_schema_id[schema_id] = envelope.catalog_entry or _default_catalog_entry(envelope)
chosen: dict[str, tuple[CultCacheSchemaCatalogEntry, bool]] = {}
for schema_id in sorted(by_id):
carrying = by_id[schema_id]
record_key = min(envelope.key for envelope in carrying)
# Records of one tier under one id are one type. Across tiers the id keeps the type its records resolve to, so a write may
# not retype records that are already there: unless the entries of both tiers own the id, which is a rename of the schema
# under a stable id, and the registered descriptor wins. A read-back record's type is its schema's name, so tiers compare
# the names of the entries that publish the id.
for tier in (True, False):
types = sorted({envelope.type for envelope, registered, _ in tiers[schema_id] if registered == tier})
if len(types) > 1:
raise SchemaConflictError(schema_id, types, record_key)
registered_names = sorted({entry.schema_name for _, registered, entry in tiers[schema_id] if registered})
arrived_names = sorted({entry.schema_name for _, registered, entry in tiers[schema_id] if not registered})
owned_by = lambda tier: any(entry.schema_id == schema_id for _, registered, entry in tiers[schema_id] if registered == tier)
if registered_names and arrived_names and registered_names[0] != arrived_names[0] and not (owned_by(True) and owned_by(False)):
raise SchemaConflictError(schema_id, sorted([registered_names[0], arrived_names[0]]), record_key)
own_registered = next(iter(canonical(entry for entry, registered in entries if registered and entry.schema_id == schema_id)), None)
own_arrived = canonical(entry for entry, registered in entries if not registered and entry.schema_id == schema_id)
own_registered_all = canonical(entry for entry, registered in entries if registered and entry.schema_id == schema_id)
# Entries of one tier that share an own id must be one schema; a registered entry settles a disagreement among arrived ones.
names = sorted({entry.schema_name for entry in (own_registered_all or own_arrived)})
if len(names) > 1:
raise SchemaConflictError(schema_id, names, record_key)
if own_registered is not None:
pick, registered_pick = own_registered, True
elif own_arrived:
pick, registered_pick = own_arrived[0], False
else:
listed_registered = next(iter(canonical(entry for entry, registered in entries if registered and schema_id in entry.compatible_schema_ids)), None)
listed_arrived = next(iter(canonical(entry for entry, registered in entries if not registered and schema_id in entry.compatible_schema_ids)), None)
pick = listed_registered if listed_registered is not None else listed_arrived
registered_pick = listed_registered is not None
if pick is None:
continue
prior = chosen.get(pick.schema_id)
if prior is not None and prior[1] and not registered_pick:
continue
chosen[pick.schema_id] = (pick, registered_pick)
for schema_id in sorted(by_id):
if not any(entry.schema_id == schema_id or schema_id in entry.compatible_schema_ids for entry, _ in chosen.values()):
raise SchemaConflictError(
schema_id, sorted(entry.schema_name for entry, _ in chosen.values()), min(envelope.key for envelope in by_id[schema_id])
)
return [entry for entry, _ in chosen.values()]
def retyped(held: CultCacheEnvelope, written: CultCacheEnvelope) -> SchemaConflictError:
"""The refusal of a write that would put a second type under a key the store already holds (C# CacheBackingStore.Retyped)."""
return SchemaConflictError(
_schema_id_for(held),
[held.type],
held.key,
message=(
f"Record '{held.key}' is a {held.type} (schema id '{_schema_id_for(held)}'); writing a {written.type} there would "
"retype the key. Remove the record first, in the same batch or an earlier commit, then add the other type's."
),
)
def written_owns(held: CultCacheEnvelope, written: CultCacheEnvelope) -> bool:
"""Whether a write of `written` may replace `held` at its key (C# WrittenOwns): the written type's entry declares the held
record's schema id (its own id or one it lists compatible), or it is the type of the same schema name. The same-name half
stands in for C#'s "reads the published entry whole": this runtime does not compare shapes, so it never refuses a
same-name type for dropping slots."""
stored_id = _schema_id_for(held)
entry = written.catalog_entry if written.catalog_entry is not None else _default_catalog_entry(written)
return stored_id == entry.schema_id or stored_id in entry.compatible_schema_ids or held.type == written.type
def _encode_snapshot(
supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope], format_version: str
) -> tuple[list[Any], dict[str, str]]:
# A key names one record whatever its type, so a supplied record replaces the arrived one by key alone, and only of the
# type that owns it: retyping a key is a removal and an add, so a record another type owns under the key refuses the write.
held_by_key = {envelope.key: envelope for envelope in arrived}
landing: dict[str, CultCacheEnvelope] = {}
for envelope in supplied:
for held in (landing.get(envelope.key), held_by_key.get(envelope.key)):
if held is not None and not written_owns(held, envelope):
raise retyped(held, envelope)
held = held_by_key.get(envelope.key)
if held is not None:
envelope = replace(envelope, stored_at=stored_at_over(envelope.stored_at, held.stored_at))
landing[envelope.key] = envelope
supplied = list(landing.values())
stamps = {envelope.key: envelope.stored_at for envelope in supplied}
replaced = set(landing)
kept = [envelope for envelope in arrived if envelope.key not in replaced]
envelopes = [*kept, *supplied]
catalog = [
_encode_catalog_entry(entry)
for entry in sorted(
_derive_catalog([*((envelope, False) for envelope in kept), *((envelope, True) for envelope in supplied)]),
key=lambda item: (item.schema_name, item.schema_id),
)
for entry in sorted(catalog_by_schema_id.values(), key=lambda item: item.schema_name)
]
records = [
[envelope.key, _schema_id_for(envelope), envelope.stored_at, envelope.payload]
for envelope in sorted(envelopes, key=lambda item: item.key)
]
return [format_version, catalog, records], stamps
def commit_store_file(path: Path, data: bytes) -> None:
"""Writes `data` to a unique staged file beside the store, fsyncs it, replaces the store with it and, off Windows,
fsyncs the directory so the replace is durable. A failure before the replace removes the staged file and leaves the
store untouched. The caller holds the store lock."""
staged = path.with_name(f"{path.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}")
try:
fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666)
try:
view = memoryview(data)
while view:
view = view[os.write(fd, view):]
os.fsync(fd)
finally:
os.close(fd)
_replace_store(staged, path)
except BaseException:
try:
os.unlink(staged)
except OSError:
pass
raise
if sys.platform != "win32":
directory = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
REPLACE_ATTEMPTS = 25
REPLACE_RETRY_SECONDS = 0.02
_ERROR_ACCESS_DENIED = 5
_ERROR_SHARING_VIOLATION = 32
_ERROR_LOCK_VIOLATION = 33
_POSIX_UNSUPPORTED = (1, 50, 87) # incorrect function, not supported, invalid parameter: FAT, exFAT, before Windows 10 1607
MOVEFILE_REPLACE_WRITE_THROUGH = 0x9 # MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
class ReplaceKernel:
"""The kernel32 calls a Windows store replace needs, each answering 0 or a Win32 error code."""
def rename_posix(self, staged: str, destination: str) -> int:
"""CreateFileW(staged, DELETE|SYNCHRONIZE), then FileRenameInfoEx with REPLACE_IF_EXISTS|POSIX_SEMANTICS, then CloseHandle."""
raise NotImplementedError
def move_file_ex(self, staged: str, destination: str, flags: int) -> int:
raise NotImplementedError
def sleep(self, seconds: float) -> None:
time.sleep(seconds)
def _replace_store(staged: str | os.PathLike[str], destination: str | os.PathLike[str]) -> None:
"""Replaces `destination` with `staged`: POSIX-semantics rename first on Windows (so a reader holding the store open
keeps it and rarely sees it absent; a stalled rename can still leave it absent for 5-47 ms on NTFS,
variants:ruling:posix-rename-absent-window-document), os.replace elsewhere."""
if sys.platform == "win32":
replace_store_windows(os.fspath(staged), os.fspath(destination), WindowsReplaceKernel())
else:
os.replace(staged, destination)
def replace_store_windows(staged: str, destination: str, kernel: ReplaceKernel) -> None:
"""POSIX-semantics rename, falling back for the rest of the call to write-through MoveFileExW where POSIX semantics are
unsupported; errors 5, 32 and 33 retry up to 25 times 20 ms apart, any other code raises at once. The TypeScript and C#
runtimes take the same decision table."""
posix = True
last = 0
for _ in range(REPLACE_ATTEMPTS):
code = kernel.rename_posix(staged, destination) if posix else kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH)
if posix and code in _POSIX_UNSUPPORTED:
posix = False
code = kernel.move_file_ex(staged, destination, MOVEFILE_REPLACE_WRITE_THROUGH)
if code == 0:
return
if code not in (_ERROR_ACCESS_DENIED, _ERROR_SHARING_VIOLATION, _ERROR_LOCK_VIOLATION):
raise _replace_error(f"CultCache could not replace the store: Win32 error {code}.", code)
last = code
kernel.sleep(REPLACE_RETRY_SECONDS)
raise _replace_error(f"CultCache could not replace the store after {REPLACE_ATTEMPTS} attempts: last Win32 error {last}.", last)
def _replace_error(message: str, code: int) -> OSError:
error = OSError(message)
error.winerror = code # type: ignore[attr-defined]
return error
class WindowsReplaceKernel(ReplaceKernel):
"""kernel32 bound by ctypes, as _try_lock_windows binds it. Windows only."""
def __init__(self) -> None:
import ctypes
from ctypes import wintypes
self._ctypes = ctypes
k = self._k = ctypes.WinDLL("kernel32", use_last_error=True)
k.CreateFileW.restype = wintypes.HANDLE
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
k.SetFileInformationByHandle.restype = wintypes.BOOL
k.SetFileInformationByHandle.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
k.CloseHandle.restype = wintypes.BOOL
k.CloseHandle.argtypes = [wintypes.HANDLE]
k.MoveFileExW.restype = wintypes.BOOL
k.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
def rename_posix(self, staged: str, destination: str) -> int:
ctypes, k = self._ctypes, self._k
delete_synchronize, share_all, open_existing, normal = 0x00110000, 0x7, 3, 0x80
handle = k.CreateFileW(staged, delete_synchronize, share_all, None, open_existing, normal, None)
if handle is None or ctypes.c_void_p(handle).value in (None, ctypes.c_void_p(-1).value):
return ctypes.get_last_error()
try:
# FILE_RENAME_INFO: Flags (DWORD), RootDirectory (pointer-aligned HANDLE), FileNameLength (DWORD), FileName (WCHAR[]).
name = os.path.abspath(destination).encode("utf-16-le")
pointer = ctypes.sizeof(ctypes.c_void_p)
length_at = pointer + pointer
info = ctypes.create_string_buffer(length_at + 4 + len(name) + 2)
info[0:4] = (0x3).to_bytes(4, "little") # FILE_RENAME_FLAG_REPLACE_IF_EXISTS | FILE_RENAME_FLAG_POSIX_SEMANTICS
info[length_at:length_at + 4] = len(name).to_bytes(4, "little")
info[length_at + 4:length_at + 4 + len(name)] = name
file_rename_info_ex = 22
if k.SetFileInformationByHandle(handle, file_rename_info_ex, info, len(info)):
return 0
return ctypes.get_last_error()
finally:
k.CloseHandle(handle)
def move_file_ex(self, staged: str, destination: str, flags: int) -> int:
return 0 if self._k.MoveFileExW(staged, destination, flags) else self._ctypes.get_last_error()
class StoreLinkedError(OSError):
"""A write was asked of a store path that is a link: a replace there would swap the link for a regular file."""
def __init__(self, path: Path) -> None:
super().__init__(f"CultCache store {path} is a link, and a write at it would replace the link; configure the link's target as the store path.")
self.path = path
def _refuse_linked(path: Path) -> None:
"""Refuses a link at the store path: a file or directory link, and on Windows a junction or any reparse point (islink misses
junctions before Python 3.12). Absent is not a link."""
linked = os.path.islink(path)
if not linked and sys.platform == "win32":
try:
linked = bool(os.lstat(path).st_file_attributes & 0x400) # FILE_ATTRIBUTE_REPARSE_POINT
except FileNotFoundError:
pass
if linked:
raise StoreLinkedError(path)
@contextmanager
def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
"""Holds the exclusive store lock on `<path>.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix,
LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is
created if absent and never written or deleted."""
_refuse_linked(path)
lock_path = path.with_name(path.name + ".lock")
deadline = time.monotonic() + timeout
while True:
release = _try_lock(lock_path)
if release is not None:
break
if time.monotonic() >= deadline:
raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.")
time.sleep(_LOCK_POLL_SECONDS)
try:
# A link may have been put at the path while the lock was awaited; the look above only saw the path before.
_refuse_linked(path)
yield
finally:
release()
def _try_lock(lock_path: Path) -> Callable[[], None] | None:
if sys.platform == "win32":
return _try_lock_windows(lock_path)
import fcntl
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o666)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(fd)
return None
return lambda: os.close(fd) # closing drops the flock
def _try_lock_windows(lock_path: Path) -> Callable[[], None] | None:
import ctypes
import msvcrt
from ctypes import wintypes
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p]
fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_BINARY, 0o666)
handle = msvcrt.get_osfhandle(fd)
overlapped = ctypes.create_string_buffer(32)
# LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY over the whole range, as the TypeScript runtime takes it.
if not kernel.LockFileEx(handle, 0x3, 0, 0xFFFFFFFF, 0xFFFFFFFF, overlapped):
os.close(fd)
return None
return lambda: os.close(fd) # closing drops the lock
return [STORE_FORMAT_VERSION, catalog, records]
def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None:
@ -606,32 +186,27 @@ def _describe_header(header: str) -> str:
return f"an unrecognised {_STORE_FORMAT_PREFIX}* header of {len(header.encode('utf-8'))} bytes"
def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None:
def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None:
if not isinstance(decoded, list) or not decoded:
return None
if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] not in _READABLE_STORE_FORMATS:
if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION:
raise ValueError(
f"CultCache store format {_describe_header(decoded[0])} is not one this runtime reads; "
f"it reads {STORE_FORMAT_VERSION!r} and {STORE_FORMAT_ELEMENT_IDS!r} only"
f"it reads {STORE_FORMAT_VERSION!r} only"
)
if decoded[0] not in _READABLE_STORE_FORMATS:
if decoded[0] != STORE_FORMAT_VERSION:
return None
if len(decoded) != 3:
raise ValueError(f"CultCache store has {len(decoded)} top-level slots; this runtime reads 3 (header, schema catalog, records)")
if not isinstance(decoded[1], list) or not isinstance(decoded[2], list):
if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list):
raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array")
# An id names the entry that owns it; only an id no entry owns names the entry that lists it as compatible.
entries = [_decode_catalog_entry(raw_entry) for raw_entry in decoded[1]]
catalog_by_schema_id: dict[str, CultCacheSchemaCatalogEntry] = {}
for entry in entries:
catalog_by_schema_id.setdefault(entry.schema_id, entry)
for entry in entries:
for raw_entry in decoded[1]:
entry = _decode_catalog_entry(raw_entry)
catalog_by_schema_id[entry.schema_id] = entry
for compatible_schema_id in entry.compatible_schema_ids:
catalog_by_schema_id.setdefault(compatible_schema_id, entry)
catalog_by_schema_id[compatible_schema_id] = entry
envelopes: list[CultCacheEnvelope] = []
seen: set[str] = set()
for raw_record in decoded[2]:
if not isinstance(raw_record, list) or len(raw_record) < 4:
raise ValueError("CultCache persisted records must be MessagePack arrays")
@ -644,16 +219,25 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
key, schema_id, stored_at, payload = raw_record
if not isinstance(key, str) or not key:
raise ValueError("CultCache persisted records must declare a key")
if key in seen:
raise ValueError(f"CultCache record {key!r} appears more than once")
seen.add(key)
if not isinstance(schema_id, str) or not schema_id:
raise ValueError("CultCache persisted records must declare a schema id")
if not isinstance(stored_at, str) or not stored_at:
raise ValueError("CultCache persisted records must declare storedAt")
catalog_entry = catalog_by_schema_id.get(schema_id)
if catalog_entry is None:
raise ValueError(f"CultCache persisted record '{key}' references schema id '{schema_id}', which the store's catalog does not publish")
schema_version = _infer_schema_version_from_payload(_normalize_payload(payload))
schema_name = _infer_schema_name(schema_version) if schema_version is not None else None
if schema_name is None:
raise ValueError(f'CultCache persisted record "{key}" references missing schema id "{schema_id}"')
catalog_entry = CultCacheSchemaCatalogEntry(
schema_id=schema_id,
schema_name=schema_name,
schema_version=schema_version,
content_hash=schema_id,
canonical_schema_json="",
compatible_schema_ids=(),
members=(),
)
normalized_payload = _normalize_payload(payload)
envelopes.append(
CultCacheEnvelope(
@ -665,20 +249,16 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None
catalog_entry=catalog_entry,
)
)
return decoded[0], envelopes
return envelopes
def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]:
def _decode_legacy_envelopes(decoded: Any, msgpack: Any) -> list[CultCacheEnvelope]:
if not isinstance(decoded, list):
raise ValueError("CultCache MessagePack store is not a recognized snapshot")
envelopes: list[CultCacheEnvelope] = []
seen: set[Any] = set()
for item in decoded:
if not isinstance(item, dict):
raise ValueError("CultCache legacy envelopes must be MessagePack maps")
if item.get("key") in seen:
raise ValueError(f"CultCache record {item['key']!r} appears more than once")
seen.add(item.get("key"))
try:
payload = item["payload"]
envelopes.append(
@ -723,14 +303,12 @@ def _decode_catalog_entry(value: Any) -> CultCacheSchemaCatalogEntry:
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid compatible schema ids')
if not isinstance(members, list):
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has invalid members')
if not isinstance(canonical_schema_json, str):
raise ValueError(f'CultCache schema catalog entry "{schema_id}" has an invalid canonical schema')
return CultCacheSchemaCatalogEntry(
schema_id=schema_id,
schema_name=schema_name,
schema_version=schema_version if isinstance(schema_version, str) and schema_version else f"{schema_name}.v1",
content_hash=content_hash if isinstance(content_hash, str) and content_hash else schema_id,
canonical_schema_json=canonical_schema_json,
canonical_schema_json=canonical_schema_json if isinstance(canonical_schema_json, str) else "",
compatible_schema_ids=tuple(compatible_schema_ids),
members=tuple(_decode_catalog_member(member) for member in members),
)
@ -775,65 +353,6 @@ def _schema_id_for(envelope: CultCacheEnvelope) -> str:
return envelope.schema_id or envelope.type
_STORED_AT_SHAPE = re.compile(
r"([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]{1,9}))?(?:(Z)|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))"
)
def parse_stored_at(text: Any) -> datetime | None:
"""The UTC instant a stored_at text names, or None. Only the shape the runtimes write is a stamp: an RFC 3339 date-time with T,
an optional fraction of 1 to 9 digits (cut to the microsecond) and Z or a numeric offset of hours 00-23 and minutes 00-59. Checked here, not left to
fromisoformat, whose accepted set differs by interpreter (3.10 and 3.13) and from Rust and TypeScript. An instant outside
datetime's range is not a stamp."""
if not isinstance(text, str):
return None
shape = _STORED_AT_SHAPE.fullmatch(text)
if shape is None:
return None
year, month, day, hour, minute, second, fraction, zulu, sign, offset_hours, offset_minutes = shape.groups()
try:
offset = timezone.utc if zulu else timezone(
(-1 if sign == "-" else 1) * timedelta(hours=int(offset_hours), minutes=int(offset_minutes))
)
local = datetime(
int(year), int(month), int(day), int(hour), int(minute), int(second), int(((fraction or "") + "000000")[:6]), offset
)
return local.astimezone(timezone.utc)
except (ValueError, OverflowError):
return None
def mint_stored_at(previous: str | None, now: datetime) -> str:
"""A stored_at later than `previous` (C# MintStoredAt): `now`, or the microsecond after `previous` when `now` is not later.
A previous that does not parse leaves `now`. Raises ValueError, nothing minted, when `previous` is the last instant a stamp
can name, so a write over it is refused and writes nothing."""
now = now.astimezone(timezone.utc)
last = parse_stored_at(previous)
if last is not None and now <= last:
try:
now = last + timedelta(microseconds=1)
except OverflowError:
raise ValueError(f"stored_at {previous!r} is the last instant a stamp can name; a write over it cannot be stored later") from None
return now.isoformat(timespec="microseconds")
def stored_at_over(staged: str, replaced: str) -> str:
"""The stored_at a record replacing `replaced` is written with (C# StoredAtOver): `staged` when both parse and it is strictly
later, else one minted over `replaced`. No content is compared: every replacing write is stamped later."""
later, earlier = parse_stored_at(staged), parse_stored_at(replaced)
if later is not None and earlier is not None and later > earlier:
return staged
return mint_stored_at(replaced, datetime.now(timezone.utc))
def record_version(envelope: CultCacheEnvelope) -> str:
"""The content version of a stored record: the lowercase hex SHA-256 of its persisted encoding, the array
[key, schema id, stored_at, payload] exactly as the single-file store writes it
(variants:ruling:write-set-content-version-declared-ownership)."""
record = [envelope.key, _schema_id_for(envelope), envelope.stored_at, envelope.payload]
return hashlib.sha256(msgpack.packb(record, use_bin_type=True)).hexdigest()
def _default_catalog_entry(envelope: CultCacheEnvelope) -> CultCacheSchemaCatalogEntry:
schema_id = _schema_id_for(envelope)
return CultCacheSchemaCatalogEntry(
@ -857,3 +376,27 @@ def _normalize_payload(payload: Any) -> bytes:
if isinstance(payload, list) and all(isinstance(item, int) and 0 <= item <= 255 for item in payload):
return bytes(payload)
raise ValueError("CultCache record payload must be binary MessagePack bytes")
def _infer_schema_version_from_payload(payload: bytes) -> str | None:
try:
msgpack = SingleFileMessagePackBackingStore._msgpack()
decoded = msgpack.unpackb(payload, raw=False)
except Exception:
return None
if isinstance(decoded, list) and decoded and isinstance(decoded[0], str):
return decoded[0]
if isinstance(decoded, dict):
value = decoded.get("schemaVersion", decoded.get("schema_version"))
return value if isinstance(value, str) else None
return None
def _infer_schema_name(schema_version: str) -> str | None:
marker = schema_version.rfind(".v")
if marker <= 0:
return None
version = schema_version[marker + 2:]
if not version or not version.isdigit():
return None
return schema_version[:marker]

View file

@ -1,38 +0,0 @@
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
import msgpack
from cultcache_py import SingleFileMessagePackBackingStore
from cultcache_py.stores import record_version
# variants:ruling:write-set-content-version-declared-ownership. The vector in tests/vectors/content-version is written only by
# the C# reference; every row of it is computed here from the store file, by the store's read and by record_version.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "content-version"
class ContentVersionVectorTests(unittest.TestCase):
def test_every_row_of_the_vector_equals_this_runtimes_version(self) -> None:
rows = msgpack.unpackb((VECTORS / "versions.msgpack").read_bytes(), raw=False)
self.assertEqual([key for key, _ in rows], ["alpha", "beta"])
directory = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, directory, ignore_errors=True)
copy = directory / "store.cc"
shutil.copyfile(VECTORS / "store.cc", copy)
store = SingleFileMessagePackBackingStore(copy)
by_key = {envelope.key: envelope for envelope in store.pull_all()}
for key, version in rows:
with self.subTest(key=key):
found = store.read(key)
self.assertIsNotNone(found)
self.assertEqual(found[1], version)
self.assertEqual(record_version(by_key[key]), version)
self.assertIsNone(store.read("gamma"))
if __name__ == "__main__":
unittest.main()

View file

@ -12,8 +12,6 @@ from cultcache_py import (
CultCache,
JsonLinesBackingStore,
SingleFileMessagePackBackingStore,
StoreLinkedError,
StoreUnreadableError,
define_database_entry_type,
define_document_type,
)
@ -86,7 +84,7 @@ class CultCacheTests(unittest.TestCase):
)
cache.pull_all_backing_stores()
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=50))
cache.put(document, "item:potion", Item(name="Potion", category="Consumable", value=55))
cache.update(document, "item:potion", lambda item: Item(item.name, item.category, item.value + 5))
self.assertEqual(cache.get_required(document, "item:potion").value, 55)
cache.delete(document, "item:potion")
self.assertIsNone(cache.get(document, "item:potion"))
@ -104,20 +102,20 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(cache.get_key_by_name(document, "Elixir"), "item:potion")
self.assertEqual(cache.get_by_index(document, "category", "Rare").name, "Elixir")
def test_incremental_lookup_delete_restores_duplicate_name_owner(self) -> None:
def test_incremental_lookup_delete_restores_duplicate_owner(self) -> None:
document = item_doc()
cache = CultCache()
cache.register_document_type(document)
cache.put(document, "item:first", Item(name="Potion", category="Consumable", value=50))
cache.put(document, "item:second", Item(name="Potion", category="Rare", value=60))
cache.put(document, "item:second", Item(name="Potion", category="Consumable", value=60))
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:second")
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:second")
cache.delete(document, "item:second")
self.assertEqual(cache.get_key_by_name(document, "Potion"), "item:first")
self.assertEqual(cache.get_key_by_index(document, "category", "Consumable"), "item:first")
self.assertIsNone(cache.get_key_by_index(document, "category", "Rare"))
def test_database_entry_formatter_uses_slot_indexed_messagepack_array(self) -> None:
try:
@ -188,7 +186,7 @@ class CultCacheTests(unittest.TestCase):
self.assertEqual(raw[2][0][0], "app")
self.assertEqual(raw[2][0][1], "settings")
def test_messagepack_store_refuses_a_record_whose_schema_the_catalog_does_not_publish(self) -> None:
def test_messagepack_store_recovers_schema_stamped_record_missing_catalog_entry(self) -> None:
try:
import msgpack # type: ignore
except ModuleNotFoundError:
@ -229,490 +227,11 @@ class CultCacheTests(unittest.TestCase):
.build()
)
# The payload opens with a schema version, and the catalog is the store's own description: no recovery.
with self.assertRaises(StoreUnreadableError):
cache.pull_all_backing_stores()
def _foreign_id_document(self):
return define_database_entry_type(
"tests.foreign-id",
[("name", 0)],
schema_id="tests.foreign-id.current",
schema_name="tests.foreign-id",
schema_version="tests.foreign_id.v1",
compatible_schema_ids=["tests.foreign-id.current", "tests.foreign-id.older"],
)
def _open_foreign_id_store(self, document, path: Path) -> CultCache:
cache = CultCache.builder().register_document_type(document).add_generic_store(
SingleFileMessagePackBackingStore(path)
).build()
cache.pull_all_backing_stores()
return cache
# A record put under an id its schema only lists as compatible is stamped with the registered id, and the store the write
# leaves opens and takes the next write.
def test_put_envelope_under_a_compatible_schema_id_writes_a_store_that_reopens(self) -> None:
import msgpack # type: ignore
from cultcache_py import CultCacheSchemaCatalogEntry
document = self._foreign_id_document()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
cache = self._open_foreign_id_store(document, path)
payload = document.encode_payload({"name": "foreign"})
foreign_entry = CultCacheSchemaCatalogEntry(
schema_id="tests.foreign-id.older",
schema_name="tests.foreign-id",
schema_version="tests.foreign_id.v1",
content_hash="tests.foreign-id.older",
canonical_schema_json="",
compatible_schema_ids=("tests.foreign-id.older",),
members=(),
)
cache.put_envelope(
document,
CultCacheEnvelope(
key="foreign",
type="tests.foreign-id",
payload=payload,
stored_at="2026-09-30T00:00:00Z",
schema_id="tests.foreign-id.older",
catalog_entry=foreign_entry,
),
)
stored = msgpack.unpackb(path.read_bytes(), raw=False)
self.assertEqual([entry[0] for entry in stored[1]], ["tests.foreign-id.current"])
self.assertEqual(stored[2][0][1], "tests.foreign-id.current")
reopened = self._open_foreign_id_store(document, path)
self.assertEqual(reopened.get_required(document, "foreign")["name"], "foreign")
reopened.put(document, "next", {"name": "next"})
self.assertEqual(self._open_foreign_id_store(document, path).get_required(document, "next")["name"], "next")
def test_the_store_publishes_a_records_schema_id_even_when_the_envelopes_catalog_entry_does_not(self) -> None:
import msgpack # type: ignore
from cultcache_py import CultCacheSchemaCatalogEntry
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
store.push(CultCacheEnvelope(
key="k",
type="tests.store-publishes",
payload=msgpack.packb({"name": "k"}, use_bin_type=True),
stored_at="2026-09-30T00:00:00Z",
schema_id="tests.store-publishes.record",
catalog_entry=CultCacheSchemaCatalogEntry(
schema_id="tests.store-publishes.other",
schema_name="tests.store-publishes",
schema_version="tests.store_publishes.v1",
content_hash="tests.store-publishes.other",
canonical_schema_json="",
compatible_schema_ids=("tests.store-publishes.other",),
members=(),
),
))
stored = msgpack.unpackb(path.read_bytes(), raw=False)
self.assertEqual(stored[2][0][1], "tests.store-publishes.record")
self.assertEqual([entry[0] for entry in stored[1]], ["tests.store-publishes.record"])
self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 1)
# The catalog a write leaves is derived from its records: one entry per carried id, chosen and never merged, the same whatever
# order the records arrive in. A registered (supplied) entry wins over an arrived (read back) entry with the same own id.
@staticmethod
def _writer_entry(schema_id: str, name: str, content_hash: str, compatible: tuple[str, ...]):
from cultcache_py import CultCacheSchemaCatalogEntry
return CultCacheSchemaCatalogEntry(
schema_id=schema_id, schema_name=name, schema_version=name + ".v1", content_hash=content_hash,
canonical_schema_json="", compatible_schema_ids=compatible, members=(),
)
def _writer_record(self, key: str, type: str, schema_id: str, catalog_entry=None) -> CultCacheEnvelope:
import msgpack # type: ignore
return CultCacheEnvelope(
key=key, type=type, payload=msgpack.packb({"key": key}, use_bin_type=True),
stored_at="2026-09-30T00:00:00Z", schema_id=schema_id, catalog_entry=catalog_entry,
)
@staticmethod
def _raw_entry(entry) -> list:
return [entry.schema_id, entry.schema_name, entry.schema_version, entry.content_hash, "", list(entry.compatible_schema_ids), []]
def test_a_registered_entry_is_written_as_it_is_over_the_arrived_entries_with_the_same_own_id_in_either_order(self) -> None:
import msgpack # type: ignore
stale1 = self._writer_entry("tests.x", "tests.n", "a-stale-1", ("tests.x", "old"))
stale2 = self._writer_entry("tests.x", "tests.n", "a-stale-2", ("tests.x",))
registered = self._writer_record("a", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "fresh", ("tests.x",)))
for entries in ([stale1, stale2], [stale2, stale1]):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1", [self._raw_entry(entry) for entry in entries],
[["b", "tests.x", "2026-09-30T00:00:00Z", msgpack.packb({"b": 1}, use_bin_type=True)]],
], use_bin_type=True))
SingleFileMessagePackBackingStore(path).push(registered)
catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1]
self.assertEqual(len(catalog), 1)
self.assertEqual(catalog[0][3], "fresh")
self.assertEqual(catalog[0][5], ["tests.x"], "written as chosen, no union with the arrived entries' ids")
def test_a_rename_with_a_stable_schema_id_a_whole_view_write_names_the_registered_schema(self) -> None:
import msgpack # type: ignore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.old", "stale", ("tests.x",)))],
[["a", "tests.x", "2026-09-30T00:00:00Z", msgpack.packb({"a": 1}, use_bin_type=True)]],
], use_bin_type=True))
SingleFileMessagePackBackingStore(path).push_all(
[self._writer_record("a", "tests.old", "tests.x", self._writer_entry("tests.x", "tests.new", "fresh", ("tests.x",)))]
)
catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1]
self.assertEqual([(entry[1], entry[3]) for entry in catalog], [("tests.new", "fresh")])
def test_two_entries_of_one_tier_that_share_an_own_id_and_disagree_on_the_schema_name_refuse_the_write_typed(self) -> None:
from cultcache_py import SchemaConflictError
first = self._writer_record("b", "tests.first", "tests.x", self._writer_entry("tests.x", "tests.first", "h1", ("tests.x",)))
second = self._writer_record("a", "tests.first", "tests.x", self._writer_entry("tests.x", "tests.second", "h2", ("tests.x",)))
for order in ([first, second], [second, first]):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
with self.assertRaises(SchemaConflictError) as refused:
SingleFileMessagePackBackingStore(path).push_all(order)
self.assertEqual(refused.exception.schema_id, "tests.x")
self.assertEqual(refused.exception.record_key, "a")
self.assertEqual(refused.exception.schema_names, ["tests.first", "tests.second"])
self.assertFalse(path.exists())
def test_records_of_different_types_under_one_schema_id_refuse_the_write_typed_and_no_record_changes_type(self) -> None:
from cultcache_py import SchemaConflictError
a = self._writer_record("a", "tests.n", "tests.y", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y")))
b = self._writer_record("b", "tests.m", "tests.y", self._writer_entry("tests.y", "tests.m", "h2", ("tests.y",)))
for order in ([a, b], [b, a]):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(SchemaConflictError) as refused:
SingleFileMessagePackBackingStore(Path(tmp) / "store.msgpack").push_all(order)
self.assertEqual(refused.exception.schema_id, "tests.y")
self.assertEqual(refused.exception.schema_names, ["tests.m", "tests.n"])
def test_an_entry_that_owns_an_id_is_chosen_over_one_that_lists_it(self) -> None:
import msgpack # type: ignore
owner = self._writer_record("a", "tests.n", "tests.y", self._writer_entry("tests.y", "tests.n", "h1", ("tests.y",)))
lister = self._writer_record("b", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x", "tests.y")))
for order in ([lister, owner], [owner, lister]):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
SingleFileMessagePackBackingStore(path).push_all(order)
catalog = msgpack.unpackb(path.read_bytes(), raw=False)[1]
self.assertEqual(sorted((entry[0], entry[5]) for entry in catalog), [("tests.x", ["tests.x", "tests.y"]), ("tests.y", ["tests.y"])])
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
SingleFileMessagePackBackingStore(path).push_all([owner])
self.assertEqual([entry[0] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["tests.y"])
def test_entries_of_one_tier_that_tie_are_taken_in_one_fixed_order_and_a_registered_lister_beats_an_arrived_one(self) -> None:
import msgpack # type: ignore
# The content hash orders them against the order their compatible ids would give: h1 sorts first, its list sorts last.
one = self._writer_record("a", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x",)))
two = self._writer_record("b", "tests.n", "tests.x", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y")))
for order in ([one, two], [two, one]):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
SingleFileMessagePackBackingStore(path).push_all(order)
self.assertEqual([entry[3] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["h1"])
# z sits under old, which the arrived entry (id x) lists; the supplied entry (id r) lists it too: the registered one publishes it.
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.n", "stale", ("tests.x", "old")))],
[["z", "old", "2026-09-30T00:00:00Z", msgpack.packb({"z": 1}, use_bin_type=True)]],
], use_bin_type=True))
SingleFileMessagePackBackingStore(path).push(
self._writer_record("a", "tests.n", "tests.r", self._writer_entry("tests.r", "tests.n", "fresh", ("tests.r", "old")))
)
self.assertEqual([entry[0] for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], ["tests.r"])
# A registered entry that owns an id keeps it against an arrived entry that owns it too and lists the id another record sits
# under, whichever of the two ids sorts first: the arrived entry's list is not merged in, so the record under the listed id is
# published by no chosen entry.
def test_an_arrived_entry_that_owns_a_registered_id_does_not_replace_it_nor_publish_what_it_lists(self) -> None:
import msgpack # type: ignore
from cultcache_py import SchemaConflictError
for own_id, listed_id in (("tests.a", "tests.b"), ("tests.b", "tests.a")):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1", [self._raw_entry(self._writer_entry(own_id, "tests.arrived", "stale", (own_id, listed_id)))],
[["k2", listed_id, "2026-09-30T00:00:00Z", msgpack.packb({"k": 2}, use_bin_type=True)]],
], use_bin_type=True))
before = path.read_bytes()
with self.assertRaises(SchemaConflictError) as refused:
SingleFileMessagePackBackingStore(path).push(
self._writer_record("k1", "tests.registered", own_id, self._writer_entry(own_id, "tests.registered", "fresh", (own_id,)))
)
self.assertEqual(refused.exception.schema_id, listed_id)
self.assertEqual(refused.exception.record_key, "k2")
self.assertEqual(path.read_bytes(), before, "the file is left as it was")
# Ids are taken in sorted order, so which entry survives, and so which id the refusal names, does not depend on the order the
# records arrive in.
def test_the_refusal_names_the_same_id_whatever_order_the_records_arrive_in(self) -> None:
from cultcache_py import SchemaConflictError
one = self._writer_record("r1", "tests.n", "tests.y", self._writer_entry("tests.x", "tests.n", "h1", ("tests.x", "tests.y")))
two = self._writer_record("r2", "tests.n", "tests.z", self._writer_entry("tests.x", "tests.n", "h2", ("tests.x", "tests.z")))
for order in ([one, two], [two, one]):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(SchemaConflictError) as refused:
SingleFileMessagePackBackingStore(Path(tmp) / "store.msgpack").push_all(order)
self.assertEqual(refused.exception.schema_id, "tests.y")
self.assertEqual(refused.exception.record_key, "r1")
# A write may not retype records already in the file: across tiers the id keeps the type its records resolve to, unless the
# entries of both tiers own it, which is a rename under a stable id and the registered descriptor wins.
def test_a_write_that_would_change_the_type_an_existing_record_resolves_to_refuses_typed_and_leaves_the_file(self) -> None:
import msgpack # type: ignore
from cultcache_py import SchemaConflictError
cases = [
("registered entry lists the id, arrived entry owns it",
self._writer_entry("tests.x", "tests.arrived", "h1", ("tests.x",)),
self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.r", "tests.registered", "h2", ("tests.r", "tests.x")))),
("registered entry owns the id, arrived entry lists it",
self._writer_entry("tests.r", "tests.arrived", "h1", ("tests.r", "tests.x")),
self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.x", "tests.registered", "h2", ("tests.x",)))),
]
payload = msgpack.packb({"z": 1}, use_bin_type=True)
with tempfile.TemporaryDirectory() as tmp:
for name, catalog, registered in cases:
path = Path(tmp) / (name.split(",")[0].replace(" ", "-") + ".msgpack")
path.write_bytes(msgpack.packb(
["cultcache.store.v1", [self._raw_entry(catalog)], [["z", "tests.x", "2026-09-30T00:00:00Z", payload]]], use_bin_type=True))
before = path.read_bytes()
with self.assertRaises(SchemaConflictError, msg=name) as refused:
SingleFileMessagePackBackingStore(path).push(registered)
self.assertEqual(refused.exception.schema_id, "tests.x", name)
self.assertEqual(sorted(refused.exception.schema_names), ["tests.arrived", "tests.registered"], name)
self.assertEqual(path.read_bytes(), before, name)
# The same registered record renames the schema when both tiers own the id: it writes, and the reader finds every record.
path = Path(tmp) / "rename.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.arrived", "h1", ("tests.x",)))],
[["z", "tests.x", "2026-09-30T00:00:00Z", payload]],
], use_bin_type=True))
SingleFileMessagePackBackingStore(path).push(
self._writer_record("a", "tests.registered", "tests.x", self._writer_entry("tests.x", "tests.registered", "h2", ("tests.x",)))
)
self.assertEqual([(entry[0], entry[1]) for entry in msgpack.unpackb(path.read_bytes(), raw=False)[1]], [("tests.x", "tests.registered")])
self.assertEqual(len(SingleFileMessagePackBackingStore(path).pull_all()), 2)
def test_a_record_no_chosen_entry_publishes_refuses_the_write_typed_and_leaves_the_file(self) -> None:
import msgpack # type: ignore
from cultcache_py import SchemaConflictError
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1", [self._raw_entry(self._writer_entry("tests.x", "tests.legacy", "stale", ("tests.x", "old")))],
[["z", "old", "2026-09-30T00:00:00Z", msgpack.packb({"z": 1}, use_bin_type=True)]],
], use_bin_type=True))
before = path.read_bytes()
with self.assertRaises(SchemaConflictError) as refused:
SingleFileMessagePackBackingStore(path).push(
self._writer_record("a", "tests.legacy", "tests.x", self._writer_entry("tests.x", "tests.legacy", "fresh", ("tests.x",)))
)
self.assertEqual(refused.exception.schema_id, "old")
self.assertEqual(refused.exception.record_key, "z")
self.assertEqual(path.read_bytes(), before)
def test_an_id_an_entry_owns_names_that_entry_not_one_that_lists_it_as_compatible(self) -> None:
for vector in ("own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"):
store = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "readability" / vector)
self.assertEqual([envelope.type for envelope in store.pull_all()], ["vectors.item", "vectors.item"], vector)
def test_a_record_loaded_under_a_compatible_schema_id_is_written_back_under_the_id_its_catalog_entry_carries(self) -> None:
import msgpack # type: ignore
document = self._foreign_id_document()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1",
[["tests.foreign-id.current", "tests.foreign-id", "tests.foreign_id.v1", "tests.foreign-id.current", "",
["tests.foreign-id.current", "tests.foreign-id.older"], []]],
[["old", "tests.foreign-id.older", "2026-09-30T00:00:00Z", document.encode_payload({"name": "old"})]],
], use_bin_type=True))
cache = self._open_foreign_id_store(document, path)
self.assertEqual(cache.get_required_envelope(document, "old").schema_id, "tests.foreign-id.current")
cache.put(document, "next", {"name": "next"})
reopened = self._open_foreign_id_store(document, path)
self.assertEqual(reopened.get_required(document, "old")["name"], "old")
self.assertEqual(reopened.get_required(document, "next")["name"], "next")
# The cache restamps a record on load, so a store's writer never has to: a record loaded under an id the registered document
# only lists is held, and written back by a whole write, under the id its catalog entry carries. A document that does not list
# the id a record sits under cannot have that record published by a write that leaves it in place: the put is refused, typed.
def test_a_cache_that_does_not_list_the_id_a_loaded_record_sits_under_is_refused_a_put_and_the_file_is_left(self) -> None:
import msgpack # type: ignore
from cultcache_py import SchemaConflictError
document = define_database_entry_type(
"tests.foreign-id", [("name", 0)], schema_id="tests.foreign-id.current",
schema_name="tests.foreign-id", schema_version="tests.foreign_id.v1",
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(msgpack.packb([
"cultcache.store.v1",
[["tests.foreign-id.current", "tests.foreign-id", "tests.foreign_id.v1", "tests.foreign-id.current", "",
["tests.foreign-id.current", "tests.foreign-id.older"], []]],
[["old", "tests.foreign-id.older", "2026-09-30T00:00:00Z", document.encode_payload({"name": "old"})]],
], use_bin_type=True))
before = path.read_bytes()
cache = self._open_foreign_id_store(document, path)
self.assertEqual(cache.get_required_envelope(document, "old").schema_id, "tests.foreign-id.current")
with self.assertRaises(SchemaConflictError) as refused:
cache.put(document, "next", {"name": "next"})
self.assertEqual(refused.exception.schema_id, "tests.foreign-id.older")
self.assertEqual(refused.exception.record_key, "old")
self.assertEqual(path.read_bytes(), before)
# A record resolves to a local document by its schema id, never by the name its catalog entry carries: a schema renamed under
# a stable id opens, and an id that names one document is not overruled by a name that names another.
def _store_of(self, path: Path, catalog: list, records: list) -> None:
import msgpack # type: ignore
path.write_bytes(msgpack.packb(["cultcache.store.v1", catalog, records], use_bin_type=True))
def test_a_schema_renamed_under_a_stable_id_opens_by_its_id(self) -> None:
document = define_database_entry_type(
"tests.old", [("name", 0)], schema_id="tests.stable", schema_name="tests.old", schema_version="tests.old.v1",
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
self._store_of(
path,
[["tests.stable", "tests.renamed", "tests.renamed.v1", "renamed", "", ["tests.stable"], []]],
[["a", "tests.stable", "2026-09-30T00:00:00Z", document.encode_payload({"name": "a"})]],
)
cache = self._open_foreign_id_store(document, path)
self.assertEqual(cache.get_required(document, "a")["name"], "a")
def test_a_schema_id_that_names_one_document_is_not_overruled_by_a_name_that_names_another(self) -> None:
first = define_database_entry_type("tests.a", [("name", 0)], schema_id="id.a", schema_name="tests.a", schema_version="tests.a.v1")
second = define_database_entry_type(
"tests.b", [("name", 0)], schema_id="id.b", schema_name="tests.b", schema_version="tests.b.v1", compatible_schema_ids=["id.b", "id.b.older"],
)
with tempfile.TemporaryDirectory() as tmp:
for schema_id in ("id.b", "id.b.older"):
path = Path(tmp) / (schema_id + ".msgpack")
# The entry that publishes the id carries the other document's name.
self._store_of(
path,
[["id.b", "tests.a", "tests.b.v1", "h", "", ["id.b", "id.b.older"], []]],
[["k", schema_id, "2026-09-30T00:00:00Z", second.encode_payload({"name": "k"})]],
)
cache = CultCache.builder().register_document_type(first).register_document_type(second).add_generic_store(
SingleFileMessagePackBackingStore(path)
).build()
cache.pull_all_backing_stores()
self.assertEqual(cache.get_required(second, "k")["name"], "k", schema_id)
self.assertIsNone(cache.get(first, "k"), schema_id)
def _assert_second_claim_refused(self, first, second, schema_id: str) -> None:
"""A registration refusal is typed, names the id and both schema names and document types, and the cache keeps what it
had."""
from cultcache_py import SchemaConflictError
cache = CultCache()
cache.register_document_type(first)
with self.assertRaises(SchemaConflictError) as refused:
cache.register_document_type(second)
self.assertEqual(refused.exception.schema_id, schema_id)
self.assertEqual(refused.exception.schema_names, [first.catalog_entry().schema_name, second.catalog_entry().schema_name])
self.assertIn(first.type, str(refused.exception))
self.assertIn(second.type, str(refused.exception))
if second is not first:
with self.assertRaisesRegex(CultCacheError, "not registered"):
cache.put(second, "k", {"name": "k"})
def _pull(self, documents: list, catalog: list, records: list) -> CultCache:
tmp = tempfile.mkdtemp()
path = Path(tmp) / "store.msgpack"
self._store_of(path, catalog, records)
builder = CultCache.builder()
for document in documents:
builder = builder.register_document_type(document)
cache = builder.add_generic_store(SingleFileMessagePackBackingStore(path)).build()
cache.pull_all_backing_stores()
return cache
def test_a_document_listing_an_id_another_owns_registers_beside_it_in_either_order_and_the_owner_wins(self) -> None:
owner = define_database_entry_type("tests.owner", [("name", 0)], schema_id="id.owned", schema_name="tests.owner", schema_version="tests.owner.v1")
lister = define_database_entry_type(
"tests.lister", [("name", 0)], schema_id="id.lister", schema_name="tests.lister", schema_version="tests.lister.v1",
compatible_schema_ids=["id.lister", "id.owned"],
)
catalog = [
["id.owned", "tests.owner", "tests.owner.v1", "h1", "", ["id.owned"], []],
["id.lister", "tests.lister", "tests.lister.v1", "h2", "", ["id.lister", "id.owned"], []],
]
records = [
["o", "id.owned", "2026-09-30T00:00:00Z", owner.encode_payload({"name": "o"})],
["l", "id.lister", "2026-09-30T00:00:00Z", lister.encode_payload({"name": "l"})],
]
for order in ([owner, lister], [lister, owner]):
cache = self._pull(order, catalog, records)
self.assertEqual(cache.get_required(owner, "o")["name"], "o")
self.assertIsNone(cache.get(lister, "o"))
self.assertEqual(cache.get_required(lister, "l")["name"], "l")
self.assertEqual(self._pull([lister], catalog, records[:1]).get_required(lister, "o")["name"], "o")
def test_two_documents_listing_one_id_register_and_a_record_under_it_needs_an_owner(self) -> None:
from cultcache_py import SchemaConflictError
a = define_database_entry_type(
"tests.list-a", [("name", 0)], schema_id="id.a", schema_name="tests.list-a", compatible_schema_ids=["id.a", "id.shared"],
)
b = define_database_entry_type(
"tests.list-b", [("name", 0)], schema_id="id.b", schema_name="tests.list-b", compatible_schema_ids=["id.b", "id.shared"],
)
owner = define_database_entry_type("tests.shared", [("name", 0)], schema_id="id.shared", schema_name="tests.shared")
catalog = [["id.shared", "tests.shared", "tests.shared.v1", "h", "", ["id.shared"], []]]
records = [["k", "id.shared", "2026-09-30T00:00:00Z", owner.encode_payload({"name": "k"})]]
for listers in ([a, b], [b, a]):
with self.assertRaises(SchemaConflictError) as refused:
self._pull(listers, catalog, records)
self.assertEqual(refused.exception.schema_id, "id.shared")
self.assertEqual(refused.exception.record_key, "k")
self.assertEqual(refused.exception.schema_names, ["tests.list-a", "tests.list-b"])
for documents in ([*listers, owner], [owner, *listers]):
cache = self._pull(documents, catalog, records)
self.assertEqual(cache.get_required(owner, "k")["name"], "k")
def test_two_documents_owning_one_schema_id_are_refused_in_either_order(self) -> None:
# Each declares an older id and not its own, so the id they share is only ever their own.
a = define_database_entry_type("tests.own-a", [("name", 0)], schema_id="id.same", schema_name="tests.own-a", compatible_schema_ids=["id.a-older"])
b = define_database_entry_type("tests.own-b", [("name", 0)], schema_id="id.same", schema_name="tests.own-b", compatible_schema_ids=["id.b-older"])
self._assert_second_claim_refused(a, b, "id.same")
self._assert_second_claim_refused(b, a, "id.same")
def test_every_registration_refusal_is_a_schema_conflict(self) -> None:
first = define_database_entry_type("tests.named", [("name", 0)], schema_id="id.first", schema_name="tests.named")
same_name = define_database_entry_type("tests.renamed", [("name", 0)], schema_id="id.second", schema_name="tests.named")
self._assert_second_claim_refused(first, same_name, "id.second")
self._assert_second_claim_refused(first, first, "id.first")
cache.pull_all_backing_stores()
self.assertEqual(cache.get_required(document, "record-1")["value"], "still readable")
envelope = cache.get_required_envelope(document, "record-1")
self.assertEqual(envelope.type, "runtime-policy")
self.assertEqual(envelope.schema_id, "sha256:stale-schema-id-from-cold-record")
def test_interop_cli_helpers_round_trip_v1_store(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@ -1122,25 +641,6 @@ class CultCacheTests(unittest.TestCase):
catalog_entry=catalog_entry,
)
def test_pulling_a_synthetic_pre_v1_envelope_array_leaves_the_file_bytes_identical(self) -> None:
try:
import msgpack # type: ignore
except ImportError:
self.skipTest("msgpack optional dependency is not installed")
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "legacy.cc"
legacy = msgpack.packb(
[{"key": "note:hello", "type": "note", "payload": {"title": "Hello"}, "storedAt": "2026-10-20T00:00:00Z"}],
use_bin_type=True,
)
path.write_bytes(legacy)
envelopes = SingleFileMessagePackBackingStore(path).pull_all()
self.assertEqual([e.key for e in envelopes], ["note:hello"])
self.assertEqual(path.read_bytes(), legacy)
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["legacy.cc"])
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)
@ -1393,32 +893,6 @@ class CultCacheTests(unittest.TestCase):
self.assertNotIn(repr(value[0]), message)
self.assertIn(f"of {len(value[0])} bytes", message)
def test_single_file_refusals_say_exactly_what_was_found_and_what_is_read(self) -> None:
import msgpack # type: ignore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
reads = "it reads 'cultcache.store.v1' and 'cultcache.store.v3' only"
for header, found in (
("cultcache.store.v12", "'cultcache.store.v12'"),
("cultcache.store.SECRET", "an unrecognised cultcache.store.* header of 22 bytes"),
("cultcache.store.v", "an unrecognised cultcache.store.* header of 17 bytes"),
# Digits are ASCII only: two Arabic-Indic digits are four bytes and are not the known shape.
("cultcache.store.v١٢", "an unrecognised cultcache.store.* header of 21 bytes"),
):
path.write_bytes(msgpack.packb([header, [], []], use_bin_type=True))
with self.assertRaises(ValueError) as caught:
SingleFileMessagePackBackingStore(path).pull_all()
self.assertIn(f"CultCache store format {found} is not one this runtime reads; {reads}", str(caught.exception))
path.write_bytes(msgpack.packb(["cultcache.store.v1", [], [["k", "s", "t", b"\x90", "extra"]]], use_bin_type=True))
with self.assertRaises(ValueError) as caught:
SingleFileMessagePackBackingStore(path).pull_all()
self.assertIn(
"CultCache record 'k' (schema 's') has 5 slots, more than the 4 of a cultcache.store.v1 record, so this is not a valid store",
str(caught.exception),
)
def test_single_file_record_refusal_names_only_a_string_key(self) -> None:
import msgpack # type: ignore
@ -1432,10 +906,10 @@ class CultCacheTests(unittest.TestCase):
self.assertNotIn("SECRET", str(caught.exception))
self.assertIn("not a valid store", str(caught.exception))
# A write at a store path that is a link is refused: a replace there would swap the link for a regular file and
# detach the store from its target. Reads through a live link keep working.
# What a write does through a symbolic link today. R3 decides whether a write resolves
# the link or refuses it; this pins the current behaviour so that change is deliberate.
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_write_at_a_live_link_is_refused_and_nothing_changes_while_a_read_through_it_works(self) -> None:
def test_r3_decides_resolve_or_refuse_a_push_through_a_live_link_replaces_the_link(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume"
volume.mkdir()
@ -1443,91 +917,12 @@ class CultCacheTests(unittest.TestCase):
envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")
SingleFileMessagePackBackingStore(target).push(envelope)
before = target.read_bytes()
volume_before = sorted(p.name for p in volume.iterdir())
link = Path(tmp) / "link.cc"
link = Path(tmp) / "store.cc"
link.symlink_to(target)
store = SingleFileMessagePackBackingStore(link)
for write in (
lambda: store.push(replace(envelope, key="b")),
lambda: store.push_all([replace(envelope, key="b")]),
lambda: store.delete("t", "a"),
):
with self.assertRaises(StoreLinkedError) as caught:
write()
self.assertIn(str(link), str(caught.exception))
self.assertTrue(link.is_symlink())
SingleFileMessagePackBackingStore(link).push(replace(envelope, key="b"))
self.assertFalse(link.is_symlink())
self.assertTrue(link.is_file())
self.assertEqual(target.read_bytes(), before)
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["link.cc", "volume"])
self.assertEqual(sorted(p.name for p in volume.iterdir()), volume_before)
self.assertEqual([item.key for item in store.pull_all()], ["a"])
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_json_lines_write_at_a_live_link_is_refused_and_the_link_survives(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume"
volume.mkdir()
target = volume / "store.jsonl"
envelope = CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z")
JsonLinesBackingStore(target).push(envelope)
before = target.read_bytes()
link = Path(tmp) / "link.jsonl"
link.symlink_to(target)
store = JsonLinesBackingStore(link)
for write in (
lambda: store.push(replace(envelope, key="b")),
lambda: store.push_all([replace(envelope, key="b")]),
lambda: store.delete("t", "a"),
):
with self.assertRaises(StoreLinkedError):
write()
self.assertTrue(link.is_symlink())
self.assertEqual(target.read_bytes(), before)
self.assertEqual(sorted(p.name for p in Path(tmp).iterdir()), ["link.jsonl", "volume"])
self.assertEqual(sorted(p.name for p in volume.iterdir()), ["store.jsonl"])
# The advisory query answers for the held record and the cache's judges; a link at the store path is the write's to refuse.
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_is_writable_over_a_store_at_a_live_link_answers_true_while_the_cache_write_is_refused(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
volume = Path(tmp) / "volume"
volume.mkdir()
target = volume / "store.cc"
document = item_doc()
seed = CultCache.builder().register_document_type(document).add_generic_store(SingleFileMessagePackBackingStore(target)).build()
seed.put(document, "held", Item(name="Held", category="c", value=1))
before = target.read_bytes()
link = Path(tmp) / "link.cc"
link.symlink_to(target)
cache = CultCache.builder().register_document_type(document).add_generic_store(SingleFileMessagePackBackingStore(link)).build()
cache.pull_all_backing_stores()
names_before = (sorted(p.name for p in Path(tmp).iterdir()), sorted(p.name for p in volume.iterdir()))
self.assertTrue(cache.is_writable(document, "held"))
self.assertTrue(cache.is_writable(document, "fresh"))
with self.assertRaises(StoreLinkedError) as caught:
cache.put(document, "fresh", Item(name="Fresh", category="d", value=2))
self.assertEqual(Path(caught.exception.path), link)
self.assertTrue(link.is_symlink())
self.assertEqual(target.read_bytes(), before)
self.assertEqual((sorted(p.name for p in Path(tmp).iterdir()), sorted(p.name for p in volume.iterdir())), names_before)
def test_store_linked_error_is_an_os_error_carrying_the_path(self) -> None:
error = StoreLinkedError(Path("some/store.cc"))
self.assertIsInstance(error, OSError)
self.assertEqual(error.path, Path("some/store.cc"))
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_write_at_a_live_directory_link_is_refused_as_a_link(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
real = Path(tmp) / "real"
real.mkdir()
link = Path(tmp) / "dirlink"
link.symlink_to(real, target_is_directory=True)
with self.assertRaises(StoreLinkedError):
SingleFileMessagePackBackingStore(link).push(
CultCacheEnvelope(key="a", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z"))
self.assertTrue(link.is_symlink())
self.assertEqual(list(real.iterdir()), [])
@unittest.skipIf(os.name != "posix", "symbolic links need POSIX here")
def test_a_dangling_symlink_at_a_store_path_is_an_os_error_and_nothing_is_written(self) -> None:
@ -1540,7 +935,7 @@ class CultCacheTests(unittest.TestCase):
store = store_type(path)
with self.assertRaises(FileNotFoundError):
store.pull_all()
with self.assertRaises(StoreLinkedError):
with self.assertRaises(FileNotFoundError):
store.push(CultCacheEnvelope(key="k", type="t", payload=b"\x90", stored_at="2026-09-30T00:00:00Z"))
self.assertTrue(path.is_symlink())
self.assertEqual(list(volume.iterdir()), [])
@ -1571,241 +966,5 @@ class CultCacheTests(unittest.TestCase):
self.assertNotIsInstance(caught.exception, FileNotFoundError)
# The element-id marker: tests/vectors/document-variants-c2a/v3-base.msgpack is v1-base with its header replaced.
_C2A_VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c2a"
def _header_after_rewrite(self, vector: Path) -> str:
import msgpack
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes(vector.read_bytes())
store = SingleFileMessagePackBackingStore(store_path)
envelopes = store.pull_all()
self.assertEqual([e.key for e in envelopes], ["alpha", "beta"])
store.push(envelopes[0])
return msgpack.unpackb(store_path.read_bytes(), raw=False)[0]
def test_single_file_reads_a_v3_element_id_store_and_a_rewrite_keeps_the_marker(self) -> None:
self.assertEqual(self._header_after_rewrite(self._C2A_VECTORS / "v3-base.msgpack"), "cultcache.store.v3")
def test_single_file_rewrite_of_a_v1_store_stays_v1(self) -> None:
self.assertEqual(self._header_after_rewrite(self._VECTORS / "v1-base.msgpack"), "cultcache.store.v1")
def test_single_file_flush_after_the_marked_file_is_gone_writes_unmarked(self) -> None:
import msgpack
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes((self._C2A_VECTORS / "v3-base.msgpack").read_bytes())
store = SingleFileMessagePackBackingStore(store_path)
envelopes = store.pull_all()
store_path.unlink()
store.push_all(envelopes)
self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], "cultcache.store.v1")
def test_single_file_push_after_the_marked_file_is_gone_writes_unmarked(self) -> None:
import msgpack
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes((self._C2A_VECTORS / "v3-base.msgpack").read_bytes())
store = SingleFileMessagePackBackingStore(store_path)
envelopes = store.pull_all()
store_path.unlink()
store.push(envelopes[0])
self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], "cultcache.store.v1")
def test_single_file_push_all_refuses_a_store_whose_header_it_cannot_read_and_leaves_it_untouched(self) -> None:
envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all()
vectors = self._VECTORS.parent
for vector in (
self._VECTORS / "unknown-header.msgpack",
self._VECTORS / "variant-v2.msgpack",
vectors / "document-variants-c1" / "variant-store.msgpack",
):
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
store_path.write_bytes(vector.read_bytes())
before = store_path.read_bytes()
with self.assertRaisesRegex(StoreUnreadableError, "is not readable"):
SingleFileMessagePackBackingStore(store_path).push_all(envelopes)
self.assertEqual(store_path.read_bytes(), before, f"{vector.name} was rewritten")
# A file is replaced by a rewrite exactly when this runtime's own reader opens it: one verdict per file, asked by
# pull_all, push_all, push and delete alike. The bytes and every runtime's verdict:
# tests/vectors/document-variants-c2a/readability.
def test_single_file_replaces_a_file_exactly_when_it_reads(self) -> None:
import msgpack # type: ignore
root = self._C2A_VECTORS / "readability"
rows = [
line.split()
for line in (root / "manifest.txt").read_text(encoding="utf-8").splitlines()
if line and not line.startswith("#")
]
# Every vector in the folder has a manifest row.
self.assertEqual(
sorted(path.name for path in root.glob("*.bin")),
sorted(vector for vector, *_ in rows if not vector.startswith(("..", "@"))),
)
envelopes = SingleFileMessagePackBackingStore(self._C2A_VECTORS / "v3-base.msgpack").pull_all()
for vector, _, _, _, python in rows:
# "@directory": the walker makes an empty directory at the store path; it is refused and left untouched.
content = None if vector == "@directory" else (root / vector).read_bytes()
for operation in ("pull_all", "push_all", "push", "delete"):
with tempfile.TemporaryDirectory() as tmp:
store_path = Path(tmp) / "store.msgpack"
if content is None:
store_path.mkdir()
else:
store_path.write_bytes(content)
store = SingleFileMessagePackBackingStore(store_path)
run = {
"pull_all": store.pull_all,
"push_all": lambda: store.push_all(envelopes),
"push": lambda: store.push(envelopes[0]),
"delete": lambda: store.delete(envelopes[0].type, envelopes[0].key),
}[operation]
if python == "reads":
run()
if operation == "pull_all":
continue
# The replaced file is a store: it carries the header its old content decides (a v3 store keeps its
# marker, all else is v1), and it holds what the operation wrote.
expected = "cultcache.store.v3" if "v3" in vector else "cultcache.store.v1"
self.assertEqual(msgpack.unpackb(store_path.read_bytes(), raw=False)[0], expected, f"{vector} {operation}")
keys = [envelope.key for envelope in SingleFileMessagePackBackingStore(store_path).pull_all()]
if operation == "push_all":
self.assertEqual(sorted(set(keys) & {e.key for e in envelopes}), sorted(e.key for e in envelopes), f"{vector} {operation}")
elif operation == "push":
self.assertIn(envelopes[0].key, keys, f"{vector} {operation}")
else:
self.assertNotIn(envelopes[0].key, keys, f"{vector} {operation}")
elif content is None:
# A path that cannot be reached as a file stays an OSError (_read_store): not a store that failed to decode.
with self.assertRaises(OSError, msg=f"{vector} {operation}"):
run()
self.assertTrue(store_path.is_dir() and not any(store_path.iterdir()), f"{vector} {operation} touched the directory")
else:
with self.assertRaises(StoreUnreadableError, msg=f"{vector} {operation}") as refused:
run()
self.assertIsNotNone(refused.exception.__cause__, f"{vector} {operation}")
self.assertEqual(store_path.read_bytes(), content, f"{vector} {operation} rewrote a file it cannot read")
@staticmethod
def _rec(type: str, key: str, second: int = 0) -> CultCacheEnvelope:
return CultCacheEnvelope(key=key, type=type, payload=b"\x90", stored_at=f"2026-09-30T00:00:0{second}Z")
def test_a_supplied_record_replaces_an_arrived_one_of_the_same_type_by_key(self) -> None:
import msgpack # type: ignore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
store.push(self._rec("first", "k"))
store.push(self._rec("first", "k", 1))
store.push_all([self._rec("first", "k", 2), self._rec("first", "k", 3)])
records = msgpack.unpackb(path.read_bytes(), raw=False)[2]
self.assertEqual([record[0] for record in records], ["k"])
self.assertEqual([(e.type, e.stored_at) for e in store.pull_all()], [("first", "2026-09-30T00:00:03Z")])
def test_a_write_that_would_retype_a_key_is_refused_and_leaves_the_file(self) -> None:
from cultcache_py import SchemaConflictError
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
store.push(self._rec("first", "k"))
content = path.read_bytes()
for run in (
lambda: store.push(self._rec("second", "k", 1)),
lambda: store.push_all([self._rec("other", "j"), self._rec("second", "k", 1)]),
):
with self.assertRaises(SchemaConflictError) as refused:
run()
self.assertEqual(refused.exception.record_key, "k")
self.assertEqual(refused.exception.schema_names, ["first"])
self.assertIn("would retype the key", str(refused.exception))
self.assertEqual(path.read_bytes(), content)
# Retyping is a removal and an add.
store.delete("first", "k")
store.push(self._rec("second", "k", 1))
self.assertEqual([e.type for e in SingleFileMessagePackBackingStore(path).pull_all()], ["second"])
def test_a_retype_is_judged_by_the_stored_schema_id_the_written_type_declares_not_by_name(self) -> None:
from cultcache_py import SchemaConflictError
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
store.push(self._writer_record("k", "old", "id-old", self._writer_entry("id-old", "old", "h1", ("id-old",))))
content = path.read_bytes()
# A renamed type that lists the stored id compatible owns the record, so its write replaces it.
renamed = self._writer_record("k", "new", "id-new", self._writer_entry("id-new", "new", "h2", ("id-new", "id-old")))
# A type that lists nothing of the stored id and has another name does not.
stranger = self._writer_record("k", "stranger", "id-x", self._writer_entry("id-x", "stranger", "h3", ("id-x",)))
for run in (lambda: store.push(stranger), lambda: store.push_all([stranger])):
with self.assertRaises(SchemaConflictError):
run()
self.assertEqual(path.read_bytes(), content)
store.push(renamed)
self.assertEqual([(e.type, e.schema_id) for e in SingleFileMessagePackBackingStore(path).pull_all()], [("new", "id-new")])
def test_a_batch_naming_one_key_under_two_types_is_refused_whole(self) -> None:
from cultcache_py import SchemaConflictError
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
store = SingleFileMessagePackBackingStore(path)
with self.assertRaises(SchemaConflictError):
store.push_all([self._rec("first", "k"), self._rec("second", "k", 1)])
self.assertFalse(path.exists())
def test_a_cache_put_of_a_second_type_under_a_key_is_refused_and_persists_nothing_new(self) -> None:
from cultcache_py import SchemaConflictError
other = define_document_type("other", encode=lambda item: asdict(item), decode=lambda raw: Item(**raw))
document = item_doc()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
def build() -> CultCache:
cache = (
CultCache.builder()
.register_document_type(document)
.register_document_type(other)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
cache.pull_all_backing_stores()
return cache
cache = build()
cache.put(document, "shared", Item(name="A", category="c", value=1))
content = path.read_bytes()
with self.assertRaises(SchemaConflictError):
cache.put(other, "shared", Item(name="B", category="c", value=2))
self.assertEqual(path.read_bytes(), content)
self.assertIsNone(cache.get(other, "shared"))
reloaded = build()
self.assertEqual(reloaded.get_required(document, "shared").value, 1)
self.assertIsNone(reloaded.get(other, "shared"))
cache.delete(document, "shared")
cache.put(other, "shared", Item(name="B", category="c", value=2))
self.assertEqual(build().get_required(other, "shared").value, 2)
def test_a_catalog_entry_with_a_non_string_canonical_schema_is_unreadable(self) -> None:
# Direct probe of the c0 vector; its manifest row is added by the TS cut.
vector = self._C2A_VECTORS.parent / "document-variants-c0" / "current-catalog-bad-canonical.msgpack"
content = vector.read_bytes()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.msgpack"
path.write_bytes(content)
with self.assertRaises(StoreUnreadableError):
SingleFileMessagePackBackingStore(path).pull_all()
self.assertEqual(path.read_bytes(), content)
if __name__ == "__main__":
unittest.main()

View file

@ -1,97 +0,0 @@
"""The Python CultCache first call from docs/start/README.md. The docs show the region between the
`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ."""
import unittest
from pathlib import Path
from cultcache_py import UpdateOutcome
# snippet:begin
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from cultcache_py import CultCache, SingleFileMessagePackBackingStore, define_document_type
@dataclass
class Item:
name: str
category: str
value: int
item_doc = define_document_type(
"item",
encode=lambda item: asdict(item),
decode=lambda raw: Item(**raw),
name="name",
)
def first_call() -> Item:
path = Path(tempfile.mkdtemp()) / "first-call.cc"
cache = (
CultCache.builder()
.register_document_type(item_doc)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
cache.pull_all_backing_stores()
cache.put(item_doc, "item:potion", Item("Potion", "Consumable", 50)) # written to the .cc file
reopened = (
CultCache.builder()
.register_document_type(item_doc)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
reopened.pull_all_backing_stores() # read it back
assert reopened.get_by_name(item_doc, "Potion") == reopened.get_required(item_doc, "item:potion")
return reopened.get_required(item_doc, "item:potion")
# snippet:end
# snippet:update:begin
def restock(path: Path):
cache = (
CultCache.builder()
.register_document_type(item_doc)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
cache.pull_all_backing_stores()
return cache.update(item_doc, "item:potion", lambda item: Item("Potion", "Consumable", (item.value if item else 0) + 10))
# snippet:update:end
class FirstCallTests(unittest.TestCase):
def test_first_call_round_trips_through_a_second_cache(self) -> None:
self.assertEqual(first_call(), Item("Potion", "Consumable", 50))
def test_update_with_retry_through_a_second_cache(self) -> None:
path = Path(tempfile.mkdtemp()) / "restock.cc"
restock(path)
result = restock(path)
self.assertIs(result.outcome, UpdateOutcome.UPDATED)
reopened = (
CultCache.builder()
.register_document_type(item_doc)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
reopened.pull_all_backing_stores()
self.assertEqual(reopened.get_required(item_doc, "item:potion"), Item("Potion", "Consumable", 20))
def test_docs_show_the_tested_snippets(self) -> None:
source = Path(__file__).read_text(encoding="utf-8")
docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8")
for region in ("snippet", "snippet:update"):
with self.subTest(region=region):
snippet = source.split(f"# {region}:begin\n", 1)[1].split(f"# {region}:end\n", 1)[0]
self.assertIn("```python\n" + snippet + "```", docs)
if __name__ == "__main__":
unittest.main()

View file

@ -1,316 +0,0 @@
from __future__ import annotations
import re
import shutil
import tempfile
import unittest
from pathlib import Path
import msgpack
from cultcache_py.cache import CultCacheError
from cultcache_py import (
CultCache,
JsonLinesBackingStore,
SchemaConflictError,
SingleFileMessagePackBackingStore,
database_entry_field,
define_database_entry_type,
leaves as t,
)
# variants:ruling:cache-refuses-retype, write-ownership-by-written-type, unheld-removal-refused-at-commit. The vector in
# tests/vectors/key-identity is written only by the C# reference; this walks every row against the Python cache.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "key-identity"
IDS = {
"alpha": "sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe",
"beta": "sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94",
"gamma": "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d",
}
DOCUMENTS = {
label: define_database_entry_type(
f"tests.ki_{label}",
[database_entry_field("text", 0, codec=t.string)],
schema_id=schema_id,
schema_name=f"tests.ki_{label}",
)
for label, schema_id in IDS.items()
}
LABEL_OF = {document.type: label for label, document in DOCUMENTS.items()}
def file_records(path: Path) -> list[list[str]]:
return sorted([envelope.key, envelope.type] for envelope in SingleFileMessagePackBackingStore(path).pull_all())
def stored(path: Path, key: str):
for envelope in SingleFileMessagePackBackingStore(path).pull_all():
if envelope.key == key:
return envelope.schema_id, envelope.stored_at, envelope.payload
return None
class KeyIdentityVectorTests(unittest.TestCase):
def test_rows(self) -> None:
rows = msgpack.unpackb((VECTORS / "rows.msgpack").read_bytes(), raw=False)
walked = 0
for label, registered, ops, expect in rows:
with self.subTest(row=label), tempfile.TemporaryDirectory() as tmp:
self.walk(Path(tmp), registered, ops, expect)
walked += 1
self.assertEqual(walked, 12)
def walk(self, tmp: Path, registered: list[str], ops: list, expect: list) -> None:
refusal, held, file, foreign, kept = expect
state: dict = {"cache": None, "path": None, "before": None, "attached": []}
def builder() -> CultCache.builder:
made = CultCache.builder()
for name in registered:
made.register_document_type(DOCUMENTS[name])
return made
def open_cache(target) -> None:
if target == "memory":
state["cache"], state["path"] = builder().build(), None
return
path = tmp / "store.cc"
if target is not None and target != path:
shutil.copyfile(VECTORS / target, path)
state["before"] = path.read_bytes()
state["path"] = path
made = builder().add_generic_store(SingleFileMessagePackBackingStore(path))
state["cache"] = made.build()
state["cache"].pull_all_backing_stores()
def perform(op: list) -> None:
cache = state["cache"]
if op[0] == "open":
open_cache(op[1])
elif op[0] == "attach":
path = tmp / f"attached-{len(state['attached'])}.cc"
shutil.copyfile(VECTORS / op[1], path)
state["attached"].append((path, path.read_bytes()))
cache.add_backing_store(
SingleFileMessagePackBackingStore(path), [DOCUMENTS[name].type for name in op[2]]
)
cache.pull_all_backing_stores()
elif op[0] == "reopen":
open_cache(state["path"]) # the same file, no fresh copy
elif op[0] == "put":
cache.put(DOCUMENTS[op[1]], op[2], {"text": op[3]})
elif op[0] == "delete":
cache.delete(DOCUMENTS[op[1]], op[2])
else:
self.fail(f"unknown op {op[0]}")
for op in ops[:-1]:
perform(op)
if refusal is None:
perform(ops[-1])
else:
key = refusal[0]
with self.assertRaises((SchemaConflictError, ValueError, RuntimeError)) as caught:
perform(ops[-1])
if ops[-1][0] == "attach":
# The key is read out of the refusal's own text (it opens "Record 'key' is held by") and occurs only as a
# delimited token, never inside a schema name or other word.
message = str(caught.exception)
named = re.match(r"Record '([^']*)' is held by", message)
self.assertIsNotNone(named, message)
self.assertEqual(named.group(1), key)
self.assertEqual(len(re.findall(rf"(?<![A-Za-z0-9_]){re.escape(key)}(?![A-Za-z0-9_])", message)), 1)
self.assertEqual(message.count(key), 1)
for path, before in state["attached"]:
self.assertEqual(path.read_bytes(), before)
else:
self.assertIsInstance(caught.exception, SchemaConflictError)
self.assertEqual(caught.exception.record_key, key)
self.assertIn(key, str(caught.exception))
cache = state["cache"]
got_held = sorted(
[LABEL_OF[type], key, value["text"]]
for type, values in cache.snapshot().items()
for key, value in values.items()
)
self.assertEqual(got_held, held)
path = state["path"]
if file is None:
self.assertIsNone(path)
else:
self.assertEqual(file_records(path), file)
self.assertEqual([record.key for record in cache.foreign_records()], foreign)
if kept:
vector = tmp / "vector-before.cc"
vector.write_bytes(state["before"])
for key in kept:
self.assertIsNotNone(stored(vector, key))
self.assertEqual(stored(path, key), stored(vector, key))
def alpha_beta(*names: str, store=None) -> CultCache:
made = CultCache.builder()
for name in names:
made.register_document_type(DOCUMENTS[name])
if store is not None:
made.add_generic_store(store)
return made.build()
class KeyIdentityTests(unittest.TestCase):
def test_two_global_types_are_refused_in_one_cache_and_a_store_holding_both_does_not_load(self) -> None:
# C# refuses a second persisted global on pull (cultcache-store-composition.md, "Globals never invented"); the Python
# JSONL store and a cache with no store used to let two global types share __global__, so such a file can exist.
ga = define_database_entry_type(
"tests.ki_ga", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "1" * 64, global_document=True
)
gb = define_database_entry_type(
"tests.ki_gb", [database_entry_field("text", 0, codec=t.string)], schema_id="sha256:" + "2" * 64, global_document=True
)
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.jsonl"
for document in (ga, gb): # one cache per type that never pulled: how a file got two globals
writer = CultCache.builder().register_document_type(document).add_generic_store(JsonLinesBackingStore(path)).build()
writer.put_global(document, {"text": document.type})
both = CultCache.builder().register_document_type(ga).register_document_type(gb).add_generic_store(JsonLinesBackingStore(path)).build()
with self.assertRaises(CultCacheError) as caught:
both.pull_all_backing_stores()
self.assertIn("'__global__' is held by both", str(caught.exception))
self.assertEqual(both.snapshot(), {})
memory = CultCache.builder().register_document_type(ga).register_document_type(gb).build()
memory.put_global(ga, {"text": "a"})
with self.assertRaises(SchemaConflictError):
memory.put_global(gb, {"text": "b"})
self.assertEqual(memory.get_required_global(ga), {"text": "a"})
def test_every_write_path_refuses_a_second_type_and_never_echoes_the_payload(self) -> None:
alpha, beta = DOCUMENTS["alpha"], DOCUMENTS["beta"]
for name in ("put", "put_envelope", "put_envelopes"):
with self.subTest(path=name):
cache = alpha_beta("alpha", "beta")
cache.put(alpha, "k", {"text": "held"})
envelope = cache.get_envelope(alpha, "k")
other = type(envelope).create(
key="k", type=beta.type, payload=beta.encode_payload({"text": "CANARY"}), schema_id=IDS["beta"]
)
writes = {
"put": lambda: cache.put(beta, "k", {"text": "CANARY"}),
"put_envelope": lambda: cache.put_envelope(beta, other),
"put_envelopes": lambda: cache.put_envelopes(beta, [other]),
}
with self.assertRaises(SchemaConflictError) as caught:
writes[name]()
self.assertEqual(caught.exception.record_key, "k")
self.assertNotIn("CANARY", str(caught.exception))
self.assertEqual(cache.get(alpha, "k"), {"text": "held"})
self.assertIsNone(cache.get(beta, "k"))
def test_two_routed_stores_holding_one_key_refuse_the_pull_and_keep_the_earlier_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
shutil.copyfile(VECTORS / "catalog-store.cc", one)
shutil.copyfile(VECTORS / "run-store.cc", two)
cache = alpha_beta("alpha", "beta")
cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
cache.pull_all_backing_stores()
cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type])
with self.assertRaises(Exception) as caught:
cache.pull_all_backing_stores()
self.assertIn("'qx'", str(caught.exception))
self.assertEqual(cache.get(DOCUMENTS["alpha"], "qx"), {"text": "a"})
self.assertIsNone(cache.get(DOCUMENTS["beta"], "qx"))
def test_a_foreign_record_is_listed_and_its_bytes_survive_writes_and_removals_elsewhere(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
shutil.copyfile(VECTORS / "foreign-store.cc", path)
before = stored(path, "k")
cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path))
cache.pull_all_backing_stores()
[record] = cache.foreign_records()
self.assertEqual((record.key, record.schema_id, record.schema_name), ("k", IDS["gamma"], "tests.ki_gamma"))
self.assertEqual(record.stored_at, before[1])
cache.put(DOCUMENTS["alpha"], "m", {"text": "m"})
cache.delete(DOCUMENTS["alpha"], "j")
self.assertEqual(stored(path, "k"), before)
self.assertEqual(file_records(path), [["k", "tests.ki_gamma"], ["m", "tests.ki_alpha"]])
claimed = alpha_beta("alpha", "gamma", store=SingleFileMessagePackBackingStore(path))
claimed.pull_all_backing_stores()
self.assertEqual(claimed.foreign_records(), [])
self.assertEqual(claimed.get(DOCUMENTS["gamma"], "k"), {"text": "g"})
def test_the_foreign_list_is_sorted_by_utf16_code_unit_and_replaced_by_the_next_pull(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
store = SingleFileMessagePackBackingStore(path)
gamma = DOCUMENTS["gamma"]
writer = alpha_beta("gamma", store=store)
for key in ("\U00010400", "A", "b", "B"):
writer.put(gamma, key, {"text": "x"})
cache = alpha_beta("alpha", store=SingleFileMessagePackBackingStore(path))
cache.pull_all_backing_stores()
self.assertEqual([record.key for record in cache.foreign_records()], ["B", "b", "\U00010400", "A"])
for key in ("B", "b", "\U00010400", "A"):
writer.delete(gamma, key)
cache.pull_all_backing_stores()
self.assertEqual(cache.foreign_records(), [])
class OwnershipAndStoreKindTests(unittest.TestCase):
"""The cache judge answers on its own where the store has no commit guard (JSONL) or the records live in two stores."""
def test_a_jsonl_store_gets_no_help_from_a_disk_judge(self) -> None:
from cultcache_py import JsonLinesBackingStore
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.jsonl"
gamma = DOCUMENTS["gamma"]
alpha_beta("gamma", store=JsonLinesBackingStore(path)).put(gamma, "k", {"text": "g"})
before = path.read_bytes()
cache = alpha_beta("alpha", store=JsonLinesBackingStore(path))
cache.pull_all_backing_stores()
self.assertEqual([record.key for record in cache.foreign_records()], ["k"])
with self.assertRaises(SchemaConflictError):
cache.put(DOCUMENTS["alpha"], "k", {"text": "x"})
with self.assertRaises(SchemaConflictError):
cache.delete(DOCUMENTS["alpha"], "k")
self.assertEqual(path.read_bytes(), before)
cache.put(DOCUMENTS["alpha"], "j", {"text": "a"}) # the foreign line is carried through a write elsewhere
self.assertIn(b"tests.ki_gamma", path.read_bytes())
def test_a_foreign_key_in_two_stores_refuses_the_pull(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
for path in (one, two):
alpha_beta("gamma", store=SingleFileMessagePackBackingStore(path)).put(DOCUMENTS["gamma"], "k", {"text": "g"})
cache = alpha_beta("alpha")
cache.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
cache.add_backing_store(SingleFileMessagePackBackingStore(two), [DOCUMENTS["beta"].type])
with self.assertRaises(Exception) as caught:
cache.pull_all_backing_stores()
self.assertIn("'k'", str(caught.exception))
def test_a_type_that_owns_the_held_schema_id_replaces_the_record_and_the_key_keeps_one_type(self) -> None:
successor = define_database_entry_type(
"tests.ki_alpha2",
[database_entry_field("text", 0, codec=t.string)],
schema_id="sha256:" + "a" * 64,
schema_name="tests.ki_alpha2",
compatible_schema_ids=[IDS["alpha"]],
)
for routed in (False, True):
with self.subTest(routed=routed), tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp) / "one.cc", Path(tmp) / "two.cc"
made = CultCache.builder().register_document_type(DOCUMENTS["alpha"]).register_document_type(successor)
if routed:
made.add_backing_store(SingleFileMessagePackBackingStore(one), [DOCUMENTS["alpha"].type])
made.add_backing_store(SingleFileMessagePackBackingStore(two), [successor.type])
else:
made.add_generic_store(SingleFileMessagePackBackingStore(one))
cache = made.build()
cache.put(DOCUMENTS["alpha"], "k", {"text": "old"})
if routed:
with self.assertRaises(SchemaConflictError): # one key would live in two stores
cache.put(successor, "k", {"text": "new"})
self.assertEqual(cache.get(DOCUMENTS["alpha"], "k"), {"text": "old"})
else:
cache.put(successor, "k", {"text": "new"})
self.assertIsNone(cache.get(DOCUMENTS["alpha"], "k"))
self.assertEqual(cache.get(successor, "k"), {"text": "new"})
self.assertEqual(file_records(one), [["k", "tests.ki_alpha2"]])

View file

@ -1,54 +0,0 @@
from __future__ import annotations
import unittest
from pathlib import Path
import msgpack
from cultcache_py import database_entry_field, define_database_entry_type, define_record_codec, leaves as t
# tests/vectors/slot-nil is written only by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a
# str in a byte[] slot, and a record short of its last slots. This runtime accepts what the reference accepts and rewrites
# the same bytes.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "slot-nil"
def _probe():
inner = define_record_codec("SlotNilInner", [database_entry_field("x", 0, default="", codec=t.string)])
return define_database_entry_type(
"tests.slot-nil",
[
database_entry_field("s", 0, default="", codec=t.nullable(t.string)),
database_entry_field("i", 1, default=0, codec=t.int32),
database_entry_field("b", 2, default=b"", codec=t.nullable(t.bytes)),
database_entry_field("r", 3, default=None, codec=t.nullable(inner)),
database_entry_field("l", 4, default=[], codec=t.nullable(t.list(t.string))),
],
)
class SlotNilVectorTests(unittest.TestCase):
def test_slots_read_and_rewrite_exactly_what_the_reference_does_for_nil_str_in_bytes_and_short_records(self) -> None:
rows = msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)
probe = _probe()
self.assertEqual(17, len(rows))
accepted = 0
for name, payload, was_accepted, rewritten in rows:
with self.subTest(name):
if was_accepted:
self.assertEqual(rewritten.hex(), probe.encode_payload(probe.decode_payload(payload)).hex())
accepted += 1
else:
with self.assertRaises(Exception):
probe.decode_payload(payload)
self.assertEqual(15, accepted)
def test_a_character_outside_the_bmp_is_four_utf8_bytes(self) -> None:
rows = {name: (payload, rewritten) for name, payload, _, rewritten in msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)}
probe = _probe()
self.assertEqual("\U0001F600", probe.decode_payload(rows["string-astral"][0])["s"])
self.assertEqual(b"\xf0\x9f\x98\x80", probe.decode_payload(rows["bytes-str-astral"][0])["b"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,509 +0,0 @@
from __future__ import annotations
import dataclasses
import shutil
import threading
import tempfile
import unittest
from pathlib import Path
import msgpack
from cultcache_py.cache import CultCacheError
from cultcache_py import (
CultCache,
CultCachePayloadDepthError,
SlotCodec,
SchemaConflictError,
SingleFileMessagePackBackingStore,
StoreUnreadableError,
UnionArm,
UnreadSlots,
database_entry_field,
define_database_entry_type,
define_record_codec,
leaves as t,
)
import test_union_parity as _parity
from test_union_parity import (
VECTORS,
_surface_value,
_vector,
surface_document,
unit_outcome,
)
SLOT = "extra-slot" # the canary: the value of every extra slot in the vectors; no refusal may carry it
# The vectors name the C# types; this runtime names a type by its codec's name, a document by its document type.
_NAMES = {"UnionSurface": surface_document.type, "SurfaceDeclaring": surface_document.type, "Denied2": "Denied"}
slot_unit_document = define_database_entry_type(
"tests.slot_unit",
[database_entry_field("name", 0, codec=t.string), database_entry_field("outcome", 1, codec=unit_outcome)],
name="name",
)
# The reader of extra-slot-consent.cc: the surface document's slots under a new schema id that lists the old one compatible.
declaring_document = define_database_entry_type(
"tests.union_surface",
[
database_entry_field("id", 0, codec=t.string),
database_entry_field("version", 1, codec=t.int32),
database_entry_field("root", 2, codec=_parity._node),
database_entry_field("outcomes", 3, codec=t.list(_parity.outcome)),
database_entry_field("part", 4, codec=t.nullable(_parity.item)),
database_entry_field("crafted", 5, codec=t.nullable(_parity.crafted)),
database_entry_field("missing", 6, codec=_parity._node),
],
name="id",
schema_id="tests.union_surface.v2",
schema_name="tests.union_surface",
compatible_schema_ids=("tests.union_surface.wide",),
)
# file -> (type, slots carried, slots declared, every mark at depth 0): the README table.
_PAYLOADS = {
"extra-slot-record.msgpack": ("UnionSurface", 8, 7, True),
"extra-slot-nested-record.msgpack": ("Composition", 3, 2, False),
"extra-slot-arm.msgpack": ("Box", 3, 2, False),
"extra-slot-list-element.msgpack": ("Text", 3, 2, False),
"depth-skipped-at-limit.msgpack": ("UnionSurface", 8, 7, True),
}
def _copy_store_without_variants(source: Path, target: Path) -> None:
"""This runtime holds no variant records (a five-slot record makes a store unreadable here), so the store vector is
walked with its variant row removed; every other byte is the C# writer's."""
[snapshot] = list(msgpack.Unpacker(open(source, "rb"), raw=False))
snapshot[2] = [record for record in snapshot[2] if len(record) <= 4]
target.write_bytes(msgpack.packb(snapshot, use_bin_type=True))
def _open(path: Path, *documents) -> CultCache:
builder = CultCache.builder()
for document in documents:
builder.register_document_type(document)
cache = builder.add_generic_store(SingleFileMessagePackBackingStore(path)).build()
cache.pull_all_backing_stores()
return cache
def _rows():
return msgpack.unpackb(_vector("slot-outcomes.msgpack"), raw=False)
class PayloadMarkTests(unittest.TestCase):
def test_every_read_only_payload_decodes_and_names_its_mark(self) -> None:
for file, (type_name, carried, declared, depth0) in _PAYLOADS.items():
with self.subTest(file=file):
value, mark = surface_document.decode_payload_marked(_vector(file))
self.assertEqual(value, _surface_value())
self.assertEqual(mark, UnreadSlots(_NAMES.get(type_name, type_name), carried, declared, 1, depth0))
self.assertEqual(surface_document.decode_payload(_vector(file)), _surface_value())
def test_the_outcomes_manifest_still_counts_nine_rows_and_four_read_only(self) -> None:
rows = msgpack.unpackb(_vector("outcomes.msgpack"), raw=False)
self.assertEqual(len(rows), 9)
self.assertEqual([row[0] for row in rows if row[1] == "read-only"], [f for f in _PAYLOADS if f != "depth-skipped-at-limit.msgpack"])
def test_a_clean_payload_and_a_short_one_carry_no_mark(self) -> None:
self.assertEqual(surface_document.decode_payload_marked(_vector("union-surface.msgpack")), (_surface_value(), None))
slots = msgpack.unpackb(_vector("union-surface.msgpack"), raw=False)
slots[2] = [1, ["only-id"]] # a Text arm with a missing trailing slot: defaults, no mark
_, mark = surface_document.decode_payload_marked(msgpack.packb(slots, use_bin_type=True))
self.assertIsNone(mark)
def test_a_retired_slot_below_the_highest_key_does_not_mark(self) -> None:
slots = msgpack.unpackb(_vector("union-surface.msgpack"), raw=False)
# Image's declared slots are 0, 1, 3 (2 retired): four carried, four declared.
image = slots[2][1][1][2]
self.assertEqual(image[0], 5)
image[1][2] = "retired-slot-value"
_, mark = surface_document.decode_payload_marked(msgpack.packb(slots, use_bin_type=True))
self.assertIsNone(mark)
def test_the_depth_check_runs_before_the_mark(self) -> None:
with self.assertRaises(CultCachePayloadDepthError):
surface_document.decode_payload_marked(_vector("depth-skipped-past-limit.msgpack"))
def test_the_unit_arm_with_extra_slots_is_read_past_and_marked(self) -> None:
payload = msgpack.packb(["unit-arm", [2, [SLOT]]], use_bin_type=True)
value, mark = slot_unit_document.decode_payload_marked(payload)
self.assertEqual(value, {"name": "unit-arm", "outcome": UnionArm("Denied")})
self.assertEqual(mark, UnreadSlots("Denied", 1, 0, 1, False))
self.assertEqual(slot_unit_document.decode_payload_marked(msgpack.packb(["ok", [2, []]]))[1], None)
def test_a_codec_decoded_outside_a_payload_decode_notes_nothing(self) -> None:
self.assertEqual(unit_outcome.decode([2, [SLOT]]), UnionArm("Denied"))
def test_the_scope_belongs_to_its_thread(self) -> None:
def decode_elsewhere(raw):
thread = threading.Thread(target=lambda: unit_outcome.decode([2, [SLOT]]))
thread.start()
thread.join()
return raw
outer = define_database_entry_type("tests.threaded", [database_entry_field("a", 0, codec=SlotCodec(lambda v: v, decode_elsewhere))])
self.assertIsNone(outer.decode_payload_marked(msgpack.packb(["x"]))[1])
def test_a_nested_payload_decode_restores_the_outer_scope(self) -> None:
def decode_clean_inside(raw):
surface_document.decode_payload_marked(_vector("union-surface.msgpack"))
return raw
inner = define_record_codec("Inner", [database_entry_field("x", 0, codec=t.string)])
outer = define_database_entry_type(
"tests.nested",
[
database_entry_field("a", 0, codec=SlotCodec(lambda v: v, decode_clean_inside)),
database_entry_field("b", 1, codec=inner),
],
)
_, mark = outer.decode_payload_marked(msgpack.packb(["x", ["v", SLOT]]))
self.assertEqual(mark, UnreadSlots("Inner", 2, 1, 1, False))
class StoreRowTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.tmp, True)
def _store(self, name: str) -> tuple[Path, CultCache]:
path = self.tmp / name
_copy_store_without_variants(VECTORS / name, path)
documents = (declaring_document,) if name == "extra-slot-consent.cc" else (surface_document, slot_unit_document)
return path, _open(path, *documents)
def _document(self, store: str, key: str):
if key == "unit-arm":
return slot_unit_document
return declaring_document if store == "extra-slot-consent.cc" else surface_document
def test_the_variant_row_is_refused_as_a_store_not_walked_and_the_rest_of_the_store_reads_without_it(self) -> None:
# This runtime holds no variant records (ruling: refuse, not carry; the reference's marked-variant row, the one
# row with a flattenRefusedBy, is the C# reader's). A five-slot persisted record makes the whole store
# unreadable, the file is left as it was, and the store without exactly that row reads.
variants = [row for row in _rows() if row[8] is not None]
self.assertEqual([(row[0], row[1], row[8]) for row in variants], [("extra-slot-store.cc", "marked-variant", "marked")])
path = self.tmp / "extra-slot-store.cc"
shutil.copyfile(VECTORS / "extra-slot-store.cc", path)
before = path.read_bytes()
with self.assertRaises(StoreUnreadableError):
_open(path, surface_document, slot_unit_document)
self.assertEqual(path.read_bytes(), before)
_, cache = self._store("extra-slot-store.cc")
self.assertIsNone(cache.get(surface_document, "marked-variant"))
self.assertIsNotNone(cache.get(surface_document, "marked"))
def test_every_row_is_writable_exactly_where_the_vector_says_and_refuses_with_its_mark(self) -> None:
walked = 0
for store, key, writable, explained, type_name, carried, declared, occurrences, flatten in _rows():
if flatten is not None:
continue # a variant: this runtime holds none (deviation)
walked += 1
with self.subTest(store=store, key=key):
path, cache = self._store(store)
document = self._document(store, key)
before = path.read_bytes()
self.assertEqual(cache.is_writable(document, key), writable)
held = cache.get(document, key)
self.assertIsNotNone(held)
if writable:
continue
self.assertEqual(explained, False)
for attempt in (
lambda: cache.put(document, key, held),
lambda: cache.delete(document, key),
lambda: cache.put_envelope(document, cache.get_required_envelope(document, key)),
lambda: cache.put_envelopes(document, [cache.get_required_envelope(document, key)]),
lambda: cache.update(document, key, lambda value: value),
):
with self.assertRaises(SchemaConflictError) as caught:
attempt()
refusal = caught.exception
self.assertEqual(
refusal.unread_slots,
UnreadSlots(_NAMES.get(type_name, type_name), carried, declared, occurrences, refusal.unread_slots.depth0_only),
)
self.assertEqual(refusal.record_key, key)
message = str(refusal)
self.assertNotIn(SLOT, message)
self.assertIn(refusal.unread_slots.type_name, message)
self.assertIn(f"{carried} slots", message)
self.assertIn(f"declares {declared}", message)
self.assertEqual(path.read_bytes(), before)
self.assertTrue(cache.get(document, key) is held)
self.assertEqual(walked, 7) # eight rows, the variant not walked
def test_a_key_no_store_holds_and_another_key_are_writable_while_a_marked_one_is_held(self) -> None:
path, cache = self._store("extra-slot-store.cc")
self.assertFalse(cache.is_writable(surface_document, "marked"))
self.assertTrue(cache.is_writable(surface_document, "nothing-here"))
cache.put(surface_document, "other", cache.get_required(surface_document, "clean"))
self.assertEqual(cache.get(surface_document, "other"), _surface_value())
def test_consent_lands_an_explained_mark_and_refuses_the_others(self) -> None:
path, cache = self._store("extra-slot-consent.cc")
self.assertTrue(cache.is_writable(declaring_document, "consented"))
cache.put(declaring_document, "consented", cache.get_required(declaring_document, "consented"))
[stored] = [e for e in SingleFileMessagePackBackingStore(path).pull_all() if e.key == "consented"]
self.assertEqual(stored.schema_id, "tests.union_surface.v2")
self.assertEqual(len(msgpack.unpackb(stored.payload, raw=False)), 7) # the consented slot is gone
self.assertTrue(cache.is_writable(declaring_document, "consented"))
for key in ("over-consent", "nested"):
self.assertFalse(cache.is_writable(declaring_document, key), key)
with self.assertRaises(SchemaConflictError):
cache.put(declaring_document, key, cache.get_required(declaring_document, key))
def test_a_reload_marks_again_and_a_write_of_a_clean_envelope_over_a_marked_key_is_refused(self) -> None:
path, cache = self._store("extra-slot-store.cc")
envelope = cache.get_required_envelope(surface_document, "clean")
with self.assertRaises(SchemaConflictError):
cache.put_envelope(surface_document, type(envelope)(**{**envelope.__dict__, "key": "marked"}))
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(surface_document, "marked"))
def test_a_written_envelope_that_carries_extra_slots_is_held_read_only(self) -> None:
path, cache = self._store("extra-slot-store.cc")
envelope = cache.get_required_envelope(surface_document, "clean")
carrying = type(envelope)(**{**envelope.__dict__, "key": "arrived", "payload": _vector("extra-slot-arm.msgpack")})
cache.put_envelope(surface_document, carrying)
self.assertFalse(cache.is_writable(surface_document, "arrived"))
with self.assertRaises(SchemaConflictError):
cache.delete(surface_document, "arrived")
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(surface_document, "arrived"))
def test_a_batch_of_envelopes_that_carry_extra_slots_is_held_read_only(self) -> None:
path, cache = self._store("extra-slot-store.cc")
envelope = cache.get_required_envelope(surface_document, "clean")
batch = [
type(envelope)(**{**envelope.__dict__, "key": key, "payload": _vector(file)})
for key, file in (("b1", "extra-slot-arm.msgpack"), ("b2", "union-surface.msgpack"))
]
cache.put_envelopes(surface_document, batch)
self.assertEqual([cache.is_writable(surface_document, key) for key in ("b1", "b2")], [False, True])
def test_a_mark_under_an_id_the_reader_does_not_list_compatible_is_not_consented(self) -> None:
unlisted = define_database_entry_type(
"tests.union_surface",
[database_entry_field("id", 0, codec=t.string)] + [database_entry_field(n, i, codec=c) for n, i, c in (
("version", 1, t.int32), ("root", 2, _parity._node), ("outcomes", 3, t.list(_parity.outcome)),
("part", 4, t.nullable(_parity.item)), ("crafted", 5, t.nullable(_parity.crafted)), ("missing", 6, _parity._node))],
name="id",
schema_id="tests.union_surface.v2",
schema_name="tests.union_surface",
)
path = self.tmp / "unlisted.cc"
_copy_store_without_variants(VECTORS / "extra-slot-consent.cc", path)
cache = _open(path, unlisted)
self.assertFalse(cache.is_writable(unlisted, "consented"))
def test_a_written_envelope_is_judged_on_the_id_it_is_stored_under_so_a_reload_agrees(self) -> None:
path, cache = self._store("extra-slot-consent.cc")
arrived = next(e for e in SingleFileMessagePackBackingStore(path).pull_all() if e.key == "consented")
self.assertEqual(arrived.schema_id, "tests.union_surface.wide")
cache.put_envelope(declaring_document, dataclasses.replace(arrived, key="via-envelope"))
cache.put_envelopes(declaring_document, [dataclasses.replace(arrived, key="via-batch")])
in_memory = [cache.is_writable(declaring_document, k) for k in ("via-envelope", "via-batch")]
cache.pull_all_backing_stores()
self.assertEqual([cache.is_writable(declaring_document, k) for k in ("via-envelope", "via-batch")], in_memory)
self.assertEqual(in_memory, [False, False])
def test_is_writable_agrees_with_the_write_for_a_key_a_store_carries_foreign(self) -> None:
path = self.tmp / "foreign.cc"
_copy_store_without_variants(VECTORS / "extra-slot-store.cc", path)
cache = _open(path, surface_document) # tests.slot_unit is not registered: its record is carried foreign
self.assertEqual([r.key for r in cache.foreign_records()], ["unit-arm"])
self.assertFalse(cache.is_writable(surface_document, "unit-arm"))
with self.assertRaises(SchemaConflictError):
cache.put(surface_document, "unit-arm", _surface_value())
with self.assertRaises(SchemaConflictError):
cache.delete(surface_document, "unit-arm")
def test_every_key_of_a_batch_is_checked_wherever_the_marked_one_stands(self) -> None:
path, cache = self._store("extra-slot-store.cc")
before = path.read_bytes()
envelope = cache.get_required_envelope(surface_document, "clean")
fresh = dataclasses.replace(envelope, key="fresh")
marked = dataclasses.replace(envelope, key="marked")
for batch in ([fresh, marked], [marked, fresh], [fresh, dataclasses.replace(envelope, key="f2"), marked]):
with self.assertRaises(SchemaConflictError) as caught:
cache.put_envelopes(surface_document, batch)
self.assertEqual(caught.exception.record_key, "marked")
self.assertEqual(path.read_bytes(), before)
self.assertIsNone(cache.get(surface_document, "fresh"))
def test_only_the_marks_that_hold_a_record_read_only_are_kept_by_key(self) -> None:
_, store = self._store("extra-slot-store.cc")
self.assertEqual({k: v[0] for k, v in store._state.unread.items()}, {"marked": surface_document.type, "unit-arm": "tests.slot_unit"})
_, consent = self._store("extra-slot-consent.cc")
self.assertEqual(sorted(consent._state.unread), ["nested", "over-consent"]) # the consented mark is no state
def test_an_arrival_with_no_catalog_entry_under_a_listed_id_is_not_consented(self) -> None:
wide = next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented")
bare = dataclasses.replace(wide, catalog_entry=None)
class Held:
def pull_all(self):
return [bare]
def push(self, envelope): ...
def push_all(self, envelopes): ...
def delete(self, type, key): ...
cache = CultCache.builder().register_document_type(declaring_document).add_generic_store(Held()).build()
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(declaring_document, "consented"))
def test_a_removal_is_refused_where_is_writable_says_no(self) -> None:
path, cache = self._store("extra-slot-store.cc")
before = path.read_bytes()
other = define_database_entry_type("tests.other", [database_entry_field("a", 0, codec=t.string)], name="a")
cache2 = _open(self.tmp / "extra-slot-store.cc", surface_document, slot_unit_document, other)
self.assertFalse(cache2.is_writable(other, "clean"))
with self.assertRaises(SchemaConflictError):
cache2.delete(other, "clean")
self.assertEqual(path.read_bytes(), before)
self.assertIsNotNone(cache2.get(surface_document, "clean"))
self.assertTrue(cache2.is_writable(other, "nothing-here"))
def test_a_global_document_is_not_writable_at_a_key_other_than_the_global_one(self) -> None:
solo = define_database_entry_type("tests.solo", [database_entry_field("a", 0, codec=t.string)], global_document=True)
cache = CultCache.builder().register_document_type(solo).build()
self.assertFalse(cache.is_writable(solo, "elsewhere"))
with self.assertRaises(CultCacheError):
cache.put(solo, "elsewhere", {"a": "x"})
with self.assertRaises(CultCacheError):
cache.delete(solo, "elsewhere")
self.assertTrue(cache.is_writable(solo, CultCache.GLOBAL_KEY))
def test_a_reload_replaces_the_marks_wholesale(self) -> None:
path, cache = self._store("extra-slot-store.cc")
self.assertFalse(cache.is_writable(surface_document, "marked"))
# The store is repaired by its owner: the marked record is rewritten clean. The reload must forget the old mark.
[snapshot] = list(msgpack.Unpacker(open(path, "rb"), raw=False))
clean = next(r for r in snapshot[2] if r[0] == "clean")
for record in snapshot[2]:
if record[0] in ("marked", "unit-arm"):
record[3] = clean[3] if record[0] == "marked" else record[3]
snapshot[2] = [r for r in snapshot[2] if r[0] != "unit-arm"]
path.write_bytes(msgpack.packb(snapshot, use_bin_type=True))
cache.pull_all_backing_stores()
self.assertTrue(cache.is_writable(surface_document, "marked"))
self.assertEqual(cache._state.unread, {})
def test_a_listed_id_whose_catalog_reports_no_extra_slot_does_not_consent(self) -> None:
wide = next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented")
same = dataclasses.replace(wide, catalog_entry=declaring_document.catalog_entry()) # the stored entry has no slot past the reader's
class Held:
def pull_all(self):
return [same]
def push(self, envelope): ...
def push_all(self, envelopes): ...
def delete(self, type, key): ...
cache = CultCache.builder().register_document_type(declaring_document).add_generic_store(Held()).build()
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(declaring_document, "consented"))
def test_a_record_stored_under_the_readers_own_id_is_never_consented(self) -> None:
own = dataclasses.replace(
next(e for e in SingleFileMessagePackBackingStore(VECTORS / "extra-slot-consent.cc").pull_all() if e.key == "consented"),
schema_id="tests.union_surface.v2",
)
wider = dataclasses.replace(own.catalog_entry, schema_id="tests.union_surface.v2", compatible_schema_ids=("tests.union_surface.v2",))
listing = dataclasses.replace(
declaring_document, schema_id="tests.union_surface.v2", compatible_schema_ids=("tests.union_surface.wide", "tests.union_surface.v2")
)
class Held:
def pull_all(self):
return [dataclasses.replace(own, catalog_entry=wider)]
def push(self, envelope): ...
def push_all(self, envelopes): ...
def delete(self, type, key): ...
cache = CultCache.builder().register_document_type(listing).add_generic_store(Held()).build()
cache.pull_all_backing_stores()
self.assertFalse(cache.is_writable(listing, "consented"))
def test_a_removal_by_the_owner_of_a_key_held_as_another_type_removes_it_as_a_write_would_replace_it(self) -> None:
a = define_database_entry_type(
"tests.owned_a", [database_entry_field("a", 0, codec=t.string)], name="a", indexes={"by_a": "a"}
)
b = define_database_entry_type(
"tests.owned_b",
[database_entry_field("a", 0, codec=t.string)],
compatible_schema_ids=(a.catalog_entry().schema_id,),
)
path = self.tmp / "owned.cc"
writer = _open(path, a)
writer.put(a, "k", {"a": "x"})
writer.put(a, "other", {"a": "y"})
cache = _open(path, a, b)
self.assertTrue(cache.is_writable(b, "k"))
cache.delete(b, "k")
self.assertIsNone(cache.get(a, "k"))
self.assertIsNone(cache.get_by_index(a, "by_a", "x"))
self.assertEqual([e.key for e in SingleFileMessagePackBackingStore(path).pull_all()], ["other"])
self.assertTrue(cache.is_writable(b, "k"))
def test_an_owners_removal_is_one_store_call_so_the_cache_and_the_store_agree_when_it_fails(self) -> None:
a = define_database_entry_type("tests.one_a", [database_entry_field("a", 0, codec=t.string)], name="a")
b = define_database_entry_type(
"tests.one_b", [database_entry_field("a", 0, codec=t.string)], compatible_schema_ids=(a.catalog_entry().schema_id,)
)
path = self.tmp / "one.cc"
_open(path, a).put(a, "k", {"a": "x"})
class SecondDeleteFails(SingleFileMessagePackBackingStore):
calls = 0
def delete(self, type, key):
self.calls += 1
if self.calls > 1:
raise OSError("second delete")
super().delete(type, key)
store = SecondDeleteFails(path)
cache = CultCache.builder().register_document_type(a).register_document_type(b).add_generic_store(store).build()
cache.pull_all_backing_stores()
cache.delete(b, "k")
self.assertEqual(store.calls, 1)
self.assertIsNone(cache.get(a, "k"))
self.assertEqual(SingleFileMessagePackBackingStore(path).pull_all(), [])
def test_writing_another_record_and_flushing_carries_the_marked_one_unchanged(self) -> None:
path, cache = self._store("extra-slot-store.cc")
original = {e.key: e for e in SingleFileMessagePackBackingStore(path).pull_all()}
cache.put(surface_document, "clean", {**_surface_value(), "version": 9})
after = {e.key: e for e in SingleFileMessagePackBackingStore(path).pull_all()}
for key in ("marked", "unit-arm"):
self.assertEqual(after[key].payload, original[key].payload, key)
self.assertEqual(after[key].schema_id, original[key].schema_id, key)
self.assertEqual(after[key].stored_at, original[key].stored_at, key)
self.assertNotEqual(after["clean"].payload, original["clean"].payload)
self.assertEqual(after["marked"].catalog_entry, original["marked"].catalog_entry)
def test_the_vector_store_loads_under_the_reference_documents_and_marks_nothing_else(self) -> None:
path, cache = self._store("extra-slot-store.cc")
self.assertTrue(cache.is_writable(surface_document, "clean"))
self.assertFalse(cache.is_writable(surface_document, "marked"))
self.assertFalse(cache.is_writable(slot_unit_document, "unit-arm"))
self.assertEqual(
cache.get(slot_unit_document, "unit-arm"), {"name": "unit-arm", "outcome": UnionArm("Denied")}
)
if __name__ == "__main__":
unittest.main()

View file

@ -1,140 +0,0 @@
from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from cultcache_py import JsonLinesBackingStore, SingleFileMessagePackBackingStore, StoreUnreadableError
from cultcache_py.backing_store import BackingStore, CultCacheEnvelope
def envelope(key: str) -> CultCacheEnvelope:
return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
class SoftPushAllTests(unittest.TestCase):
"""A soft push writes where nothing is, skips a store it reads, and refuses one it cannot read (TS and Rust parity)."""
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = self.dir / "state.cc"
def test_over_a_readable_store_it_leaves_the_file_byte_for_byte(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
store = make(path)
store.push_all([envelope("a")])
before = path.read_bytes()
store.push_all([envelope("b")], soft=True)
self.assertEqual(before, path.read_bytes())
self.assertEqual(["a"], [item.key for item in make(path).pull_all()])
store.push_all([envelope("b")]) # a plain push still lands what the caller holds
self.assertEqual(["a", "b"], sorted(item.key for item in make(path).pull_all()))
def test_over_a_readable_store_holding_no_records_it_leaves_the_file_byte_for_byte(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
store = make(path)
store.push_all([envelope("a")])
store.delete("t", "a")
self.assertTrue(path.exists())
self.assertEqual([], make(path).pull_all())
before = path.read_bytes()
store.push_all([envelope("b")], soft=True)
self.assertEqual(before, path.read_bytes())
self.assertEqual([], make(path).pull_all())
def test_over_an_absent_path_it_writes(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
make(path).push_all([envelope("a")], soft=True)
self.assertEqual(["a"], [item.key for item in make(path).pull_all()])
def test_over_an_unreadable_file_it_refuses_and_leaves_it(self) -> None:
junk = b"\xc1\x01\x02\x03"
self.path.write_bytes(junk)
with self.assertRaises(StoreUnreadableError):
SingleFileMessagePackBackingStore(self.path).push_all([envelope("a")], soft=True)
self.assertEqual(junk, self.path.read_bytes())
def test_over_an_unreadable_jsonl_file_it_refuses_and_leaves_it(self) -> None:
junk = b"this is not json\n"
self.path.write_bytes(junk)
with self.assertRaises(ValueError):
JsonLinesBackingStore(self.path).push_all([envelope("a")], soft=True)
self.assertEqual(junk, self.path.read_bytes())
def test_the_protocol_default_push_all_decides_soft_by_what_pull_or_absent_says(self) -> None:
class Memory(BackingStore):
def __init__(self) -> None:
self.items: list[CultCacheEnvelope] | None = None # None: nothing at the path
def pull_all(self) -> list[CultCacheEnvelope]:
return list(self.items or [])
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
return None if self.items is None else list(self.items)
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
self.items = list(envelopes)
store = Memory()
store.push_all([envelope("a")], soft=True) # nothing at the path: writes
self.assertEqual(["a"], [item.key for item in store.items or []])
store.push_all([envelope("b")], soft=True) # a store with records: skips
self.assertEqual(["a"], [item.key for item in store.items or []])
store.items = []
store.push_all([envelope("b")], soft=True) # a store the reader read, empty: skips
self.assertEqual([], store.items)
store.push_all([envelope("b")]) # plain: lands
self.assertEqual(["b"], [item.key for item in store.items])
def test_a_store_that_only_pulls_all_is_read_so_a_soft_push_skips_it(self) -> None:
class PullOnly(BackingStore):
def __init__(self) -> None:
self.items: list[CultCacheEnvelope] = []
def pull_all(self) -> list[CultCacheEnvelope]:
return list(self.items)
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
self.items = list(envelopes)
store = PullOnly()
store.push_all([envelope("a")], soft=True)
self.assertEqual([], store.items)
def test_the_single_file_and_jsonl_stores_say_absent_and_read_through_pull_or_absent(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
with self.subTest(make.__name__):
path = self.dir / make.__name__
store = make(path)
self.assertIsNone(store.pull_or_absent())
store.push_all([envelope("a")])
self.assertEqual(["a"], [item.key for item in store.pull_or_absent() or []])
def test_the_protocol_default_push_all_refuses_where_its_pull_refuses(self) -> None:
class Unreadable(BackingStore):
replaced = False
def pull_all(self) -> list[CultCacheEnvelope]:
raise StoreUnreadableError(Path("x"), ValueError("junk"))
def pull_or_absent(self) -> list[CultCacheEnvelope] | None:
raise StoreUnreadableError(Path("x"), ValueError("junk"))
def _replace_all(self, envelopes: list[CultCacheEnvelope]) -> None:
Unreadable.replaced = True
with self.assertRaises(StoreUnreadableError):
Unreadable().push_all([envelope("a")], soft=True)
self.assertFalse(Unreadable.replaced)
if __name__ == "__main__":
unittest.main()

View file

@ -1,188 +0,0 @@
from __future__ import annotations
import os
import select
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
from cultcache_py import SingleFileMessagePackBackingStore, stores
from cultcache_py.stores import StoreLinkedError, store_write_lock
from cultcache_py.backing_store import CultCacheEnvelope
REPO = Path(__file__).resolve().parents[3]
TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js"
HOLD_SECONDS = 1.5
HELD_WAIT_SECONDS = 3
# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package.
PY_HOLDER = """
import fcntl, os, sys, time
fd = os.open(sys.argv[1] + '.lock', os.O_RDWR | os.O_CREAT, 0o666)
fcntl.flock(fd, fcntl.LOCK_EX)
print('HELD', flush=True)
time.sleep(float(sys.argv[2]))
"""
# A holder that is the TypeScript runtime's own withStoreWriteLock, built from packages/cultcache-ts.
TS_HOLDER = """
const { withStoreWriteLock } = require(process.argv[1]);
withStoreWriteLock(process.argv[2], async () => {
console.log('HELD');
await new Promise((r) => setTimeout(r, Number(process.argv[3]) * 1000));
});
"""
def envelope(key: str) -> CultCacheEnvelope:
return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
def start_holder(argv: list[str]) -> subprocess.Popen[str]:
holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True)
assert holder.stdout is not None
ready, _, _ = select.select([holder.stdout], [], [], HELD_WAIT_SECONDS) # a holder blocked on a leaked lock never prints
if not ready:
holder.kill()
holder.wait()
holder.stdout.close()
raise AssertionError(f"the holder never took {argv[-2]}.lock within {HELD_WAIT_SECONDS} s: an earlier writer leaked the lock")
assert holder.stdout.readline().strip() == "HELD"
holder.held_at = time.monotonic() # type: ignore[attr-defined]
return holder
@unittest.skipIf(sys.platform == "win32", "the foreign holders here use flock; Windows is covered by the TS holder")
class StoreLockTests(unittest.TestCase):
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = self.dir / "state.cc"
def assert_write_waits_for(self, holder: subprocess.Popen[str]) -> None:
self.addCleanup(holder.wait)
self.addCleanup(holder.stdout.close) # type: ignore[union-attr]
refused = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
with self.assertRaises(TimeoutError):
refused.push(envelope("blocked"))
self.assertFalse(self.path.exists(), "a write that could not take the lock must not touch the store")
SingleFileMessagePackBackingStore(self.path, lock_timeout=10).push(envelope("after"))
self.assertGreaterEqual(time.monotonic() - holder.held_at, HOLD_SECONDS - 0.1, "the write finished while the holder still held the lock") # type: ignore[attr-defined]
self.assertEqual(["after"], [e.key for e in SingleFileMessagePackBackingStore(self.path).pull_all()])
def test_write_waits_while_a_foreign_process_holds_the_lock(self) -> None:
self.assert_write_waits_for(start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)]))
@unittest.skipUnless(shutil.which("node") and TS_STORE_FILE.exists(), "needs node and a built packages/cultcache-ts (npm run build)")
def test_write_waits_while_the_typescript_runtime_holds_the_lock(self) -> None:
holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)])
self.assert_write_waits_for(holder)
def test_a_link_put_while_a_writer_waits_on_the_lock_is_refused_once_it_holds_it(self) -> None:
target = self.dir / "volume" / "store.cc"
target.parent.mkdir()
SingleFileMessagePackBackingStore(target).push(envelope("a"))
before = target.read_bytes()
writes = {
"push": lambda store: store.push(envelope("b")),
"push_all": lambda store: store.push_all([envelope("b")]),
"delete": lambda store: store.delete("t", "a"),
}
missing = self.dir / "volume" / "missing.cc"
for (name, write), link_to in [(item, to) for item in writes.items() for to in (target, missing)]:
with self.subTest(name, dangling=link_to == missing):
if os.path.lexists(self.path):
os.unlink(self.path)
holder = start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)])
self.addCleanup(holder.wait)
self.addCleanup(holder.stdout.close) # type: ignore[union-attr]
outcome: list[BaseException | None] = []
def run() -> None:
try:
write(SingleFileMessagePackBackingStore(self.path, lock_timeout=10))
outcome.append(None)
except BaseException as exc: # noqa: BLE001 - the thread hands the outcome to the test
outcome.append(exc)
writer = threading.Thread(target=run)
writer.start()
time.sleep(0.3)
os.symlink(link_to, self.path) # after the writer's pre-lock look, while it waits
writer.join()
holder.wait()
self.assertEqual(1, len(outcome))
self.assertIsInstance(outcome[0], StoreLinkedError)
self.assertTrue(os.path.islink(self.path), "the link was replaced")
self.assertEqual(before, target.read_bytes())
self.assertFalse(missing.exists(), "the dangling target was created")
os.unlink(self.path) # a link is refused before the lock, so remove it to probe the writer's own lock file
with store_write_lock(self.path, timeout=0.2): # the lock is free
pass
def test_the_lock_is_released_after_each_write(self) -> None:
store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2)
for key in "abc":
store.push(envelope(key))
self.assertEqual(["a", "b", "c"], sorted(e.key for e in store.pull_all()))
def test_concurrent_processes_lose_no_writes(self) -> None:
script = (
"import sys\n"
"from cultcache_py import SingleFileMessagePackBackingStore as S\n"
"from cultcache_py.backing_store import CultCacheEnvelope as E\n"
"s = S(sys.argv[1])\n"
"for i in range(25):\n"
" s.push(E(key=f'{sys.argv[2]}{i}', type='t', payload=b'\\x01', stored_at='x'))\n"
)
env = {**os.environ, "PYTHONPATH": str(Path(stores.__file__).parents[1])}
procs = [subprocess.Popen([sys.executable, "-c", script, str(self.path), tag], env=env) for tag in "pqr"]
for proc in procs:
self.assertEqual(0, proc.wait())
self.assertEqual(75, len(SingleFileMessagePackBackingStore(self.path).pull_all()))
class StagedWriteTests(unittest.TestCase):
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = self.dir / "state.cc"
def test_the_staged_file_is_fsynced_before_the_replace(self) -> None:
order: list[str] = []
real_fsync, real_replace = os.fsync, os.replace
with mock.patch("os.fsync", side_effect=lambda fd: (order.append("fsync"), real_fsync(fd))[1]), mock.patch(
"os.replace", side_effect=lambda a, b: (order.append("replace"), real_replace(a, b))[1]
):
SingleFileMessagePackBackingStore(self.path).push(envelope("a"))
self.assertEqual("fsync", order[0])
self.assertLess(order.index("fsync"), order.index("replace"))
def test_the_staged_name_is_unique_per_write(self) -> None:
names: list[str] = []
real_replace = os.replace
with mock.patch("os.replace", side_effect=lambda a, b: (names.append(os.fspath(a)), real_replace(a, b))[1]):
store = SingleFileMessagePackBackingStore(self.path)
store.push(envelope("a"))
store.push(envelope("b"))
self.assertEqual(2, len(set(names)))
self.assertNotIn(os.fspath(self.path) + ".tmp", names)
def test_a_failed_replace_leaves_the_store_and_no_staged_file(self) -> None:
store = SingleFileMessagePackBackingStore(self.path)
store.push(envelope("a"))
before = self.path.read_bytes()
with mock.patch("os.replace", side_effect=OSError("boom")), self.assertRaises(OSError):
store.push(envelope("b"))
self.assertEqual(before, self.path.read_bytes())
self.assertEqual([], [p.name for p in self.dir.iterdir() if ".tmp" in p.name])
if __name__ == "__main__":
unittest.main()

View file

@ -1,137 +0,0 @@
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from cultcache_py import SingleFileMessagePackBackingStore, stores
from cultcache_py.backing_store import CultCacheEnvelope
from cultcache_py.stores import MOVEFILE_REPLACE_WRITE_THROUGH, ReplaceKernel, replace_store_windows
class FakeKernel(ReplaceKernel):
def __init__(self, posix: list[int], move_ex: list[int]) -> None:
self.posix, self.move_ex = list(posix), list(move_ex)
self.log: list[str] = []
self.sleeps: list[float] = []
def rename_posix(self, staged, destination):
self.log.append("posix")
return self.posix.pop(0) if self.posix else 0
def move_file_ex(self, staged, destination, flags):
self.log.append(f"moveex:{flags}")
return self.move_ex.pop(0) if self.move_ex else 0
def sleep(self, seconds):
self.sleeps.append(seconds)
def run(posix, move_ex):
kernel = FakeKernel(posix, move_ex)
return kernel, lambda: replace_store_windows("a", "b", kernel)
class WindowsReplaceTable(unittest.TestCase):
def test_falls_back_to_write_through_move_where_posix_rename_is_unsupported(self):
self.assertEqual(MOVEFILE_REPLACE_WRITE_THROUGH, 0x9)
for unsupported in (1, 50, 87):
kernel, go = run([unsupported], [0])
go()
self.assertEqual(kernel.log, ["posix", "moveex:9"])
def test_once_on_move_the_rest_of_the_call_stays_there(self):
kernel, go = run([87], [32, 0])
go()
self.assertEqual(kernel.log, ["posix", "moveex:9", "moveex:9"])
def test_transient_codes_retry_the_same_primitive(self):
for transient in (5, 32, 33):
kernel, go = run([transient, transient, 0], [])
go()
self.assertEqual(kernel.log, ["posix", "posix", "posix"])
self.assertEqual(kernel.sleeps, [0.02, 0.02])
def test_a_working_posix_rename_is_the_whole_replace(self):
kernel, go = run([0], [])
go()
self.assertEqual(kernel.log, ["posix"])
self.assertEqual(kernel.sleeps, [])
def test_unsupported_after_the_fallback_is_an_error_not_another_fallback(self):
kernel, go = run([87], [32, 87])
with self.assertRaisesRegex(OSError, "Win32 error 87") as caught:
go()
self.assertEqual(caught.exception.winerror, 87)
self.assertEqual(kernel.log, ["posix", "moveex:9", "moveex:9"])
for unsupported in (1, 50, 87):
kernel, go = run([87], [unsupported])
with self.assertRaisesRegex(OSError, f"Win32 error {unsupported}"):
go()
self.assertEqual(kernel.log, ["posix", "moveex:9"])
def test_gives_up_after_25_attempts_naming_the_last_code(self):
kernel, go = run([32] * 30, [])
with self.assertRaisesRegex(OSError, "after 25 attempts: last Win32 error 32\\."):
go()
self.assertEqual(len(kernel.log), 25)
def test_other_codes_fail_at_once(self):
kernel, go = run([2], [])
with self.assertRaisesRegex(OSError, "Win32 error 2"):
go()
self.assertEqual(len(kernel.log), 1)
kernel, go = run([87], [3])
with self.assertRaisesRegex(OSError, "Win32 error 3"):
go()
class StoreWritesRouteThroughTheReplace(unittest.TestCase):
def test_both_stores_replace_through_replace_store(self):
seen: list[tuple[Path, Path]] = []
real = stores._replace_store
def spy(staged, destination):
seen.append((Path(staged), Path(destination)))
real(staged, destination)
with tempfile.TemporaryDirectory() as directory, mock.patch.object(stores, "_replace_store", spy):
envelope = CultCacheEnvelope(key="k", type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
binary = Path(directory) / "s.cc"
SingleFileMessagePackBackingStore(binary).push_all([envelope])
lines = Path(directory) / "s.jsonl"
stores.JsonLinesBackingStore(lines).push_all([envelope])
self.assertEqual([destination for _, destination in seen], [binary, lines])
self.assertTrue(binary.read_bytes() and lines.read_text())
def test_a_failed_jsonl_replace_leaves_no_staged_file_and_the_old_store(self):
envelope = CultCacheEnvelope(key="k", type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z")
with tempfile.TemporaryDirectory() as directory:
lines = Path(directory) / "s.jsonl"
store = stores.JsonLinesBackingStore(lines)
store.push_all([envelope])
before = lines.read_bytes()
with mock.patch.object(stores, "_replace_store", side_effect=OSError("replace refused")):
with self.assertRaises(OSError):
store.push_all([CultCacheEnvelope(key="k2", type="t", payload=b"\x02", stored_at="2026-11-06T00:00:00Z")])
self.assertEqual(lines.read_bytes(), before)
self.assertEqual([p.name for p in Path(directory).iterdir() if ".tmp" in p.name], [])
@unittest.skipUnless(sys.platform == "win32", "needs a real Windows")
def test_real_kernel_replaces_through_both_primitives(self):
kernel = stores.WindowsReplaceKernel()
with tempfile.TemporaryDirectory() as directory:
for name, replace in (("posix", kernel.rename_posix), ("moveex", lambda s, d: kernel.move_file_ex(s, d, MOVEFILE_REPLACE_WRITE_THROUGH))):
staged, store = Path(directory, name + ".tmp"), Path(directory, name)
store.write_bytes(b"old")
staged.write_bytes(b"new")
self.assertEqual(replace(str(staged), str(store)), 0)
self.assertEqual(store.read_bytes(), b"new")
self.assertEqual(kernel.rename_posix(str(Path(directory, "missing")), str(Path(directory, "x"))), 2)
if __name__ == "__main__":
unittest.main()

View file

@ -133,7 +133,6 @@ def _inner_record() -> t.SlotCodec:
[
database_entry_field("n", 0, default="", codec=t.string),
database_entry_field("w", 1, default=0.0, codec=t.float32),
database_entry_field("id", 2, default="", codec=t.string),
],
type_name=REFERENCE + "LeafInner",
)
@ -663,7 +662,7 @@ def _python_authored_leaf_record() -> dict:
return {
"id": "py", "flag": False, "blob": bytearray([1, 255]), "i": -(2**31), "l": 2**63 - 1, "f": 0.1, "d": 1e-300,
"maybe_i": None, "tags": [], "names": ("\u00e9", "z"), "any": {"k": [1, True, None, 2.5, "s"], "n": -3},
"nested": None, "items": [{"n": "one", "w": 2**53 + 2**29 + 1, "id": "000000000021"}, {"n": "two", "w": 1e-40, "id": "000000000022"}],
"nested": None, "items": [{"n": "one", "w": 2**53 + 2**29 + 1}, {"n": "two", "w": 1e-40}],
"shape": UnionArm("LeafCircle", {"r": 0.1}), "floats": [0.1, 3, -0.0, 1e39], "maybe_f": None,
}

View file

@ -68,51 +68,22 @@ image = define_record_codec(
],
type_name=_GOLDEN + "Image",
)
wrap = define_record_codec(
"Wrap",
[database_entry_field("inner", 0, codec=_node), database_entry_field("id", 1, codec=t.string)],
type_name=_GOLDEN + "Wrap",
)
wrap = define_record_codec("Wrap", [database_entry_field("inner", 0, codec=_node)], type_name=_GOLDEN + "Wrap")
node = define_union(
"Node",
{0: ("Box", box), 1: ("Text", text), 2: ("Use", use), 5: ("Image", image), 6: ("Wrap", wrap)},
type_name=_GOLDEN + "Node",
)
# The golden's Outcome: the arms of a list carry an element id, so Denied, Floor and Ceiling are one-slot records.
_id_only = lambda name: define_record_codec(name, [database_entry_field("id", 0, codec=t.string)]) # noqa: E731
outcome = define_union(
"Outcome",
{
0: (
"Pending",
define_record_codec(
"Pending",
[database_entry_field("message", 0, codec=t.string), database_entry_field("id", 1, codec=t.string)],
),
),
1: (
"Completed",
define_record_codec(
"Completed",
[database_entry_field("source_version", 0, codec=t.int32), database_entry_field("id", 1, codec=t.string)],
),
),
2: ("Denied", _id_only("Denied")),
-2147483648: ("Floor", _id_only("Floor")),
2147483647: ("Ceiling", _id_only("Ceiling")),
},
type_name=_GOLDEN + "Outcome",
)
# The codec's unit arms (no value, written [key, []]), kept apart from the golden: a document list's arms carry ids.
unit_outcome = define_union(
"Outcome",
{
0: ("Pending", define_record_codec("Pending", [database_entry_field("message", 0, codec=t.string)])),
1: ("Completed", define_record_codec("Completed", [database_entry_field("source_version", 0, codec=t.int32)])),
2: ("Denied", None),
-2147483648: ("Floor", None),
2147483647: ("Ceiling", None),
},
type_name=_GOLDEN + "UnitOutcome",
type_name=_GOLDEN + "Outcome",
)
tool = define_record_codec(
"Tool",
@ -150,41 +121,41 @@ def _surface_value() -> dict:
"root": UnionArm(
"Box",
{
"id": "0000000000c0",
"id": "root",
"children": [
UnionArm("Text", {"id": "0000000000c1", "content": "hello"}),
UnionArm("Text", {"id": "t1", "content": "hello"}),
UnionArm(
"Use",
{
"id": "0000000000c2",
"id": "u1",
"composition": {
"name": "comp",
"body": UnionArm(
"Box",
{
"id": "0000000000d0",
"id": "inner",
"children": [
UnionArm("Text", {"id": "0000000000d1", "content": "x"}),
UnionArm("Image", {"id": "0000000000d2", "uri": "uri://a", "alt": "alt"}),
UnionArm("Text", {"id": "deep", "content": "x"}),
UnionArm("Image", {"id": "img", "uri": "uri://a", "alt": "alt"}),
],
},
),
},
"slots": [
UnionArm("Text", {"id": "0000000000e1", "content": "slot"}),
UnionArm("Text", {"id": "s1", "content": "slot"}),
None,
UnionArm("Box", {"id": "0000000000e2", "children": []}),
UnionArm("Box", {"id": "b2", "children": []}),
],
},
),
UnionArm("Image", {"id": "0000000000c3", "uri": "uri://b", "alt": ""}),
UnionArm("Image", {"id": "i2", "uri": "uri://b", "alt": ""}),
],
},
),
"outcomes": [
UnionArm("Pending", {"message": "wait", "id": "0000000000a1"}),
UnionArm("Completed", {"source_version": 7, "id": "0000000000a2"}),
UnionArm("Denied", {"id": "0000000000a3"}),
UnionArm("Pending", {"message": "wait"}),
UnionArm("Completed", {"source_version": 7}),
UnionArm("Denied"),
],
"part": UnionArm("Tool", {"name": "hammer", "uses": 12}),
"crafted": UnionArm("Part", {"name": "gear", "count": 4}),
@ -261,9 +232,7 @@ class UnionSurfaceVectorTests(unittest.TestCase):
else:
read_only += 1
self.assertEqual(outcome_kind, "read-only", file)
value, mark = surface_document.decode_payload_marked(payload)
self.assertEqual(value, _surface_value(), file)
self.assertIsNotNone(mark, file) # read past and marked: tests/test_slot_readonly.py pins what the mark holds
self.assertEqual(surface_document.decode_payload(payload), _surface_value(), file)
self.assertEqual((refused, read_only), (5, 4))
@ -287,14 +256,12 @@ class UnionCodecRuleTests(unittest.TestCase):
)
def test_unit_arm_is_written_with_empty_slots_and_reads_back(self) -> None:
# The bytes are the C# reference's (tests/vectors/union-parity/unit-arm-denied.msgpack), not spelled here.
reference = _vector("unit-arm-denied.msgpack")
self.assertEqual(msgpack.packb(unit_outcome.encode(UnionArm("Denied"))), reference)
self.assertEqual(unit_outcome.decode(msgpack.unpackb(reference, raw=False)), UnionArm("Denied", None))
self.assertEqual(outcome.encode(UnionArm("Denied")), [2, []])
self.assertEqual(outcome.decode([2, []]), UnionArm("Denied", None))
def test_unit_arm_refuses_a_value_to_carry(self) -> None:
with self.assertRaises(ValueError) as caught:
unit_outcome.encode(UnionArm("Denied", {"message": self.CANARY}))
outcome.encode(UnionArm("Denied", {"message": self.CANARY}))
self.assertEqual(str(caught.exception), "Union 'Outcome' arm 2 is a unit arm and carries no value")
def test_arm_keys_are_written_as_declared(self) -> None:
@ -366,7 +333,7 @@ class UnionCodecRuleTests(unittest.TestCase):
self.assertEqual(row.decode(encoded), {"id": "r", "cells": cells})
def test_a_unit_arm_with_extra_slots_is_read_past(self) -> None:
self.assertEqual(unit_outcome.decode([2, ["extra"]]), UnionArm("Denied"))
self.assertEqual(outcome.decode([2, ["extra"]]), UnionArm("Denied"))
def test_union_arms_are_immutable_hashable_values(self) -> None:
arm = UnionArm("Denied")
@ -511,7 +478,7 @@ CANARY = "CANARY-union-golden-4c2a"
def _wrap_chain(wraps: int, leaf: UnionArm) -> UnionArm:
value = leaf
for _ in range(wraps):
value = UnionArm("Wrap", {"inner": value, "id": "0000000000b1"})
value = UnionArm("Wrap", {"inner": value})
return value
@ -528,9 +495,7 @@ class BoundsVectorTests(unittest.TestCase):
surface_document.encode_payload(surface_document.decode_payload(payload)), _vector(detail), file
)
elif outcome_kind == "read-only":
value, mark = surface_document.decode_payload_marked(payload)
self.assertEqual(value, _surface_value(), file)
self.assertIsNotNone(mark, file)
self.assertEqual(surface_document.decode_payload(payload), _surface_value(), file)
else:
with self.assertRaises(ValueError, msg=file) as caught:
surface_document.decode_payload(payload)
@ -556,9 +521,9 @@ class BoundsVectorTests(unittest.TestCase):
self.assertEqual(counts, {"accepted": 4, "refused": 7, "read-only": 1})
def test_the_value_at_the_limit_writes_the_vector_and_the_value_past_it_is_refused_on_write(self) -> None:
at_limit = {**_surface_value(), "root": _wrap_chain(126, UnionArm("Box", {"id": "0000000000f0", "children": []}))}
at_limit = {**_surface_value(), "root": _wrap_chain(126, UnionArm("Box", {"id": "leaf", "children": []}))}
self.assertEqual(surface_document.encode_payload(at_limit), _vector("depth-at-limit.msgpack"))
past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Text", {"id": "0000000000f0", "content": CANARY}))}
past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Text", {"id": "leaf", "content": CANARY}))}
with self.assertRaises(CultCachePayloadDepthError) as caught:
surface_document.encode_payload(past)
message = str(caught.exception)
@ -568,7 +533,7 @@ class BoundsVectorTests(unittest.TestCase):
self.assertNotIn(CANARY, message)
def test_a_cache_put_of_the_value_past_the_limit_refuses_and_stores_nothing(self) -> None:
past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Box", {"id": "0000000000f0", "children": []}))}
past = {**_surface_value(), "root": _wrap_chain(127, UnionArm("Box", {"id": "leaf", "children": []}))}
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
cache = (
@ -661,78 +626,6 @@ def _as_tuple(tree: object) -> object:
return tuple(_as_tuple(child) for child in tree) if isinstance(tree, list) else tree
class BytesAfterVectorTests(unittest.TestCase):
CLAUSE = "bytes after its first MessagePack value (refused on read)"
def _assert_chain_clean(self, error: BaseException, file: str) -> None:
link: BaseException | None = error
while link is not None:
self.assertNotIn(CANARY, str(link), file)
self.assertNotIsInstance(link, msgpack.ExtraData, file)
link = link.__cause__ or link.__context__
def test_every_bytes_after_vector_is_refused(self) -> None:
base = _vector("union-surface.msgpack")
entries = msgpack.unpackb(_vector("bytes-after-outcomes.msgpack"), raw=False)
self.assertEqual(len(entries), 4)
for file, outcome_kind, union, key, detail in entries:
self.assertEqual((outcome_kind, union, key, detail), ("refused", "UnionSurface", None, "bytes-after"), file)
payload = _vector(file)
self.assertTrue(payload.startswith(base), file)
count = len(payload) - len(base)
with self.assertRaises(ValueError, msg=file) as caught:
surface_document.decode_payload(payload)
error = caught.exception
self.assertIs(type(error), ValueError, file)
self.assertNotIsInstance(error, CultCachePayloadDepthError, file)
self.assertEqual(
str(error), f"{surface_document.type} payload has {count} {self.CLAUSE}.", file
)
self.assertNotIn("the limit is", str(error), file)
self._assert_chain_clean(error, file)
def test_a_single_file_store_holding_a_tail_as_a_record_payload_refuses_on_load(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "tail.cc"
shutil.copyfile(VECTORS / "union-surface.cc", path)
store = SingleFileMessagePackBackingStore(path)
[envelope] = store.pull_all()
store.push(dataclasses.replace(envelope, payload=_vector("bytes-after-nil.msgpack")))
cache = (
CultCache.builder()
.register_document_type(surface_document)
.add_generic_store(SingleFileMessagePackBackingStore(path))
.build()
)
with self.assertRaises(ValueError) as caught:
cache.pull_all_backing_stores()
self.assertIn(f"1 {self.CLAUSE}", str(caught.exception))
self.assertIn(surface_document.type, str(caught.exception))
self.assertIsNone(cache.get(surface_document, "surface"))
def test_depth_wins_over_a_tail_whatever_the_unpackers_own_stack_limit(self) -> None:
# Vector-independent: a value nested past 256 is the depth refusal even with a tail, as C# throws at container 257
# before it reaches the tail; 2000 is past msgpack's own stack, so the winner is not a library constant.
for depth in (257, 300, 900, 2000, 100000):
for tail in (b"\xc0", b"\xc1\xc1"):
with self.subTest(depth=depth, tail=len(tail)):
with self.assertRaises(CultCachePayloadDepthError) as caught:
surface_document.decode_payload(b"\x91" * depth + b"\xc0" + tail)
self.assertIn("refused on read", str(caught.exception))
for depth in (1, 255, 256):
with self.subTest(at=depth):
with self.assertRaises(ValueError) as caught:
surface_document.decode_payload(b"\x91" * depth + b"\xc0\xc0")
self.assertNotIsInstance(caught.exception, CultCachePayloadDepthError)
self.assertIn(f"1 {self.CLAUSE}", str(caught.exception))
def test_a_payload_that_ends_early_is_the_decoders_not_bytes_after(self) -> None:
truncated = _vector("union-surface.msgpack")[:-1]
with self.assertRaises(Exception) as caught:
surface_document.decode_payload(truncated)
self.assertNotIn("bytes after", str(caught.exception))
class PayloadDepthRuleTests(unittest.TestCase):
"""The nesting rule at its seam, on definitions that are not unions: the same routine serves any payload codec."""
@ -816,12 +709,8 @@ class UnionDecodeBoundsTests(unittest.TestCase):
self.assertEqual(str(caught.exception), expected)
def test_the_ends_of_the_int32_key_domain_are_unit_arms_that_read_and_write(self) -> None:
floor = _vector("unit-arm-floor.msgpack")
ceiling = _vector("unit-arm-ceiling.msgpack")
self.assertEqual(unit_outcome.decode(msgpack.unpackb(floor, raw=False)), UnionArm("Floor"))
self.assertEqual(msgpack.packb(unit_outcome.encode(UnionArm("Floor"))), floor)
self.assertEqual(unit_outcome.decode(msgpack.unpackb(ceiling, raw=False)), UnionArm("Ceiling"))
self.assertEqual(msgpack.packb(unit_outcome.encode(UnionArm("Ceiling"))), ceiling)
self.assertEqual(outcome.decode([-(2**31), []]), UnionArm("Floor"))
self.assertEqual(outcome.encode(UnionArm("Ceiling")), [2**31 - 1, []])
if __name__ == "__main__":

View file

@ -1,663 +0,0 @@
from __future__ import annotations
import itertools
import shutil
import tempfile
import unittest
from dataclasses import asdict, dataclass
from decimal import Decimal
from pathlib import Path
import msgpack
from cultcache_py import (
CultCache,
CultCacheEnvelope,
SingleFileMessagePackBackingStore,
define_database_entry_type,
define_document_type,
leaves as t,
database_entry_field,
)
from cultcache_py.cache import CultCacheError, index_text
# variants:ruling:unique-indexes. `indexes` are unique within one type, `roles` are selection aliases. The vectors in
# tests/vectors/unique-index are written by the C# reference; the rest of the behaviour is pinned here.
VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "unique-index"
@dataclass
class Item:
sku: object = None
serial: object = None
group: object = None
def refusal(key_a: str, key_b: str, schema_name: str, alias: str) -> str:
return f"Records {key_a} and {key_b} ({schema_name}) both hold unique index '{alias}'; an index value names one record."
def item_doc(type: str = "item", *, indexes=None, roles=None):
return define_document_type(
type,
encode=asdict,
decode=lambda raw: Item(**raw),
indexes={"sku": "sku", "serial": "serial"} if indexes is None else indexes,
roles={"group": "group"} if roles is None else roles,
)
def envelope(document, key: str, item: Item) -> CultCacheEnvelope:
return CultCacheEnvelope.create(key=key, type=document.type, payload=document.encode_payload(item))
def cache_of(*documents, store=None) -> CultCache:
builder = CultCache.builder()
for document in documents:
builder.register_document_type(document)
if store is not None:
builder.add_generic_store(store)
return builder.build()
class RecordingStore:
"""A store that counts what reaches it; the judge must refuse before any store call."""
def __init__(self) -> None:
self.pushed: list[str] = []
def pull_all(self):
return []
def push(self, envelope) -> None:
self.pushed.append(envelope.key)
def push_all(self, envelopes) -> None:
self.pushed.extend(envelope.key for envelope in envelopes)
def delete(self, type, key) -> None:
pass
class IndexTextTests(unittest.TestCase):
def test_canonical_text(self) -> None:
cases = [
("S-1", "S-1"),
(" a ", " a "),
("", ""),
("\x1f", "\x1f"),
("\x1c", "\x1c"),
("\x1d", "\x1d"),
("\x1e", "\x1e"),
("᠎", "᠎"),
("​", "​"),
(True, "True"),
(False, "False"),
(7, "7"),
(-7, "-7"),
(9223372036854775807, "9223372036854775807"),
(18446744073709551615, "18446744073709551615"),
(0.0, "0"),
(-0.0, "0"),
(2.0, "2"),
(-3.0, "-3"),
(1e21, "1000000000000000000000"),
(0.1, "0.1000000000000000055511151231257827021181583404541015625"),
(-0.5, "-0.5"),
(2.5e-07, "0.00000024999999999999998868702795647156467140348468092270195484161376953125"),
]
for value, text in cases:
with self.subTest(value=value):
self.assertEqual(index_text(value), text)
self.assertIsInstance(index_text(value), str)
def test_no_value(self) -> None:
blank = ["", " ", "\t", "\n", "\x0b", "\x0c", "\r", "\x85", "\xa0", " ", "
", "
", " ",
" ", " ", "  \x85 ", *map(chr, range(0x2000, 0x200B))]
for value in [None, float("nan"), float("inf"), float("-inf"), *blank]:
with self.subTest(value=repr(value)):
self.assertIsNone(index_text(value))
def test_blank_set_is_exactly_the_csharp_set(self) -> None:
csharp = {*range(0x09, 0x0E), 0x20, 0x85, 0xA0, 0x1680, *range(0x2000, 0x200B), 0x2028, 0x2029, 0x202F, 0x205F, 0x3000}
self.assertEqual(len(csharp), 25)
got = {code for code in range(0x110000) if 0xD800 > code or code > 0xDFFF if index_text(chr(code)) is None}
self.assertEqual(got, csharp)
# Python's isspace is the wrong test: it also counts U+001C-001F.
self.assertTrue("\x1f".isspace())
self.assertEqual(index_text("\x1f"), "\x1f")
def test_floats_render_exactly_not_through_str(self) -> None:
for value in (0.1, 1e-7, 123456789.123456789, 5e-324, 1.7976931348623157e308):
with self.subTest(value=value):
text = index_text(value)
self.assertNotIn("e", text.lower())
self.assertEqual(Decimal(text), Decimal(value))
self.assertFalse(text.endswith("0") and "." in text)
def test_bool_is_not_an_integer(self) -> None:
self.assertEqual(index_text(True), "True")
self.assertNotEqual(index_text(True), "1")
class UniqueIndexTests(unittest.TestCase):
def test_a_write_of_a_held_value_at_another_key_is_refused_by_every_write_path(self) -> None:
for alias in ("sku", "serial"): # two unique indexes: each is judged
other = "serial" if alias == "sku" else "sku"
paths = {
"put": lambda cache, d, k, i: cache.put(d, k, i),
"put_envelope": lambda cache, d, k, i: cache.put_envelope(d, envelope(d, k, i)),
"put_envelopes": lambda cache, d, k, i: cache.put_envelopes(d, [envelope(d, k, i)]),
}
for name, write in paths.items():
with self.subTest(alias=alias, path=name):
document = item_doc()
store = RecordingStore()
cache = cache_of(document, store=store)
cache.put(document, "item:b", Item(sku="CANARY-b", serial="canary-b"))
cache.put(document, "item:a", Item(sku="CANARY-a", serial="canary-a"))
before = cache.snapshot()
pushed = list(store.pushed)
taken = "CANARY-b" if alias == "sku" else "canary-b"
landing = Item(**{alias: taken, other: "fresh"})
with self.assertRaises(CultCacheError) as caught:
write(cache, document, "item:a", landing)
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", alias))
self.assertNotIn("anary", str(caught.exception))
self.assertEqual(cache.snapshot(), before)
self.assertEqual(store.pushed, pushed)
self.assertEqual(cache.get_key_by_index(document, alias, taken), "item:b")
self.assertIsNone(cache.get_key_by_index(document, other, "fresh"))
def test_a_record_rewriting_its_own_key_keeps_its_value(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S", serial="1", group="g"))
cache.put(document, "item:a", Item(sku="S", serial="2", group="h"))
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a")
self.assertIsNone(cache.get_key_by_index(document, "serial", "1"))
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
def test_the_refusal_names_the_schema_not_the_type(self) -> None:
document = define_document_type(
"t.item", indexes={"sku": "sku"}, schema_name="schema.Item", schema_id="schema.Item.v1"
)
cache = cache_of(document)
cache.put(document, "k:1", {"sku": "S"})
with self.assertRaises(CultCacheError) as caught:
cache.put(document, "k:2", {"sku": "S"})
self.assertEqual(str(caught.exception), refusal("k:1", "k:2", "schema.Item", "sku"))
def test_the_two_smallest_keys_are_named_in_ordinal_order(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:m", Item(sku="S"))
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(document, [envelope(document, "item:z", Item(sku="S")), envelope(document, "item:A", Item(sku="S"))])
self.assertEqual(str(caught.exception), refusal("item:A", "item:m", "item", "sku"))
def test_put_envelopes_holding_one_value_twice_is_refused_whole(self) -> None:
document = item_doc()
store = RecordingStore()
cache = cache_of(document, store=store)
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(
document,
[
envelope(document, "item:b", Item(sku="S")),
envelope(document, "item:c", Item(sku="T")),
envelope(document, "item:a", Item(sku="S")),
],
)
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku"))
self.assertEqual(cache.snapshot(), {})
self.assertEqual(store.pushed, [])
self.assertIsNone(cache.get_key_by_index(document, "sku", "T"))
def test_verdict_does_not_depend_on_batch_order(self) -> None:
document = item_doc()
rows = [("item:a", Item(sku="S", serial="1")), ("item:b", Item(sku="T", serial="1")), ("item:c", Item(sku="U"))]
for order in itertools.permutations(rows):
with self.subTest(order=[key for key, _ in order]):
cache = cache_of(document)
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(document, [envelope(document, key, item) for key, item in order])
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "serial"))
self.assertEqual(cache.snapshot(), {})
def test_put_envelopes_swapping_two_records_values_lands(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S", serial="1"))
cache.put(document, "item:b", Item(sku="T", serial="2"))
cache.put_envelopes(
document,
[envelope(document, "item:a", Item(sku="T", serial="2")), envelope(document, "item:b", Item(sku="S", serial="1"))],
)
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a")
self.assertEqual(cache.get_key_by_index(document, "serial", "1"), "item:b")
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
def test_a_batch_may_take_a_value_the_batch_releases(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S"))
cache.put_envelopes(document, [envelope(document, "item:a", Item(sku="T")), envelope(document, "item:b", Item(sku="S"))])
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:a")
def test_delete_frees_the_value(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="S"))
cache.delete(document, "item:a")
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
cache.put(document, "item:b", Item(sku="S"))
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
def test_blank_values_never_conflict(self) -> None:
document = item_doc()
cache = cache_of(document)
blanks = ["", " ", "\x85", " ", None]
for number, blank in enumerate(blanks):
cache.put(document, f"item:blank{number}", Item(sku=blank, serial=blank))
cache.put_envelopes(document, [envelope(document, "item:more", Item(sku=" ")), envelope(document, "item:more2", Item(sku=""))])
for blank in ("", " ", "\x85"):
self.assertIsNone(cache.get_key_by_index(document, "sku", blank))
# U+001F and U+FEFF are values.
cache.put(document, "item:us", Item(sku="\x1f"))
cache.put(document, "item:bom", Item(sku=""))
self.assertEqual(cache.get_key_by_index(document, "sku", "\x1f"), "item:us")
self.assertEqual(cache.get_key_by_index(document, "sku", ""), "item:bom")
with self.assertRaises(CultCacheError):
cache.put(document, "item:us2", Item(sku="\x1f"))
def test_values_compare_as_the_canonical_text(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku=1))
for same in (1, 1.0, "1"):
with self.subTest(same=same), self.assertRaises(CultCacheError):
cache.put(document, "item:b", Item(sku=same))
cache.put(document, "item:true", Item(sku=True))
self.assertEqual(cache.get_key_by_index(document, "sku", "True"), "item:true")
self.assertEqual(cache.get_key_by_index(document, "sku", "1"), "item:a")
cache.put(document, "item:f", Item(sku=0.1))
self.assertEqual(cache.get_key_by_index(document, "sku", "0.1000000000000000055511151231257827021181583404541015625"), "item:f")
def test_two_types_may_share_an_alias_and_a_value(self) -> None:
one, two = item_doc("one"), item_doc("two")
cache = cache_of(one, two)
cache.put(one, "k:1", Item(sku="S", serial="1"))
cache.put(two, "k:2", Item(sku="S", serial="1"))
self.assertEqual(cache.get_key_by_index(one, "sku", "S"), "k:1")
self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2")
cache.delete(one, "k:1")
self.assertEqual(cache.get_key_by_index(two, "sku", "S"), "k:2")
def test_roles_may_share_values_and_are_not_looked_up(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:a", Item(sku="1", group="g"))
cache.put(document, "item:b", Item(sku="2", group="g"))
self.assertIsNone(cache.get_key_by_index(document, "group", "g"))
self.assertIsNone(cache.get_by_index(document, "group", "g"))
def test_an_alias_in_both_indexes_and_roles_is_refused(self) -> None:
with self.assertRaisesRegex(ValueError, "both an index and a role"):
define_document_type("bad", indexes={"a": "a"}, roles={"a": "a"})
with self.assertRaisesRegex(ValueError, "both an index and a role"):
define_database_entry_type("bad", [("a", 0)], indexes={"a": "a"}, roles={"a": "a"})
define_document_type("fine", indexes={"a": "a"}, roles={"b": "a"})
def test_register_index_over_held_duplicates_installs_nothing(self) -> None:
document = item_doc(indexes={}, roles={})
cache = cache_of(document)
cache.put(document, "item:b", Item(sku="S", serial="1"))
cache.put(document, "item:a", Item(sku="S", serial="2"))
with self.assertRaises(CultCacheError) as caught:
cache.register_index(document, "sku", "sku")
self.assertEqual(str(caught.exception), refusal("item:a", "item:b", "item", "sku"))
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
cache.register_index(document, "serial", "serial") # an installed alias is not poisoned by the refused one
self.assertEqual(cache.get_key_by_index(document, "serial", "2"), "item:a")
# Nothing was installed, so a write sharing the refused alias's value is still admitted.
cache.put(document, "item:c", Item(sku="S", serial="3"))
def test_registration_names_an_installed_alias_a_caller_mutated_into_a_collision(self) -> None:
document = item_doc(indexes={"sku": "sku"}, roles={})
cache = cache_of(document)
cache.put(document, "item:x", Item(sku="1", serial="s1"))
cache.put(document, "item:y", Item(sku="2", serial="s2"))
cache.get_required(document, "item:y").sku = "1" # the cache hands out its held value
with self.assertRaises(CultCacheError) as caught:
cache.register_index(document, "serial", "serial")
self.assertEqual(str(caught.exception), refusal("item:x", "item:y", "item", "sku"))
def test_derive_names_the_ordinal_first_alias_then_value(self) -> None:
document = item_doc(indexes={}, roles={})
cache = cache_of(document)
cache.put(document, "item:1", Item(sku="a", serial="p", group="x"))
cache.put(document, "item:2", Item(sku="a", serial="p", group="x"))
cache.put(document, "item:3", Item(sku="b", serial="q", group="y"))
cache.put(document, "item:4", Item(sku="b", serial="q", group="y"))
with self.assertRaises(CultCacheError) as caught:
cache.register_index(document, "serial", "serial")
self.assertEqual(str(caught.exception), refusal("item:1", "item:2", "item", "serial"))
def test_a_document_in_two_unique_indexes_loads_and_registers_through_every_path(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "items.cc"
document = item_doc()
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
cache.put(document, "item:a", Item(sku="S", serial="1"))
cache.put(document, "item:b", Item(sku="T", serial="2"))
# a store written by a looser definition, then opened by this one
loose = item_doc(indexes={}, roles={})
writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path))
writer.pull_all_backing_stores()
writer.put(loose, "item:c", Item(sku="U", serial="2"))
before = path.read_bytes()
reader = cache_of(document, store=SingleFileMessagePackBackingStore(path))
with self.assertRaises(CultCacheError) as caught:
reader.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal("item:b", "item:c", "item", "serial"))
self.assertEqual(path.read_bytes(), before)
self.assertEqual(reader.snapshot(), {})
def test_a_refused_load_keeps_the_held_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "items.cc"
loose = item_doc(indexes={}, roles={})
writer = cache_of(loose, store=SingleFileMessagePackBackingStore(path))
writer.put(loose, "item:a", Item(sku="S"))
writer.put(loose, "item:b", Item(sku="S"))
document = item_doc()
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
with self.assertRaises(CultCacheError):
cache.pull_all_backing_stores()
self.assertIsNone(cache.get_key_by_index(document, "sku", "S"))
self.assertEqual(cache.get_all(document), [])
class SimulationFactTests(unittest.TestCase):
def test_simulation_fact_catalog_entry_is_unchanged_by_indexes_to_roles(self) -> None:
import re
from cultnet_py.cultmesh_contracts import SIMULATION_FACT_DOCUMENT_TYPE, simulation_fact_document as document
aliases = {"shard": "shard_id", "frame": "frame", "subject": "subject_id", "claim_kind": "claim_kind"}
self.assertEqual(document.roles, aliases)
self.assertEqual(document.indexes, {})
entry = document.catalog_entry()
snake = lambda name: re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower() # noqa: E731
twin = define_database_entry_type(
SIMULATION_FACT_DOCUMENT_TYPE,
[(snake(m.member_name), m.slot) for m in entry.members],
name="fact_id",
indexes=aliases,
schema_name=entry.schema_name,
schema_version=entry.schema_version,
)
self.assertEqual(entry.canonical_schema_json, twin.catalog_entry().canonical_schema_json)
self.assertEqual(entry.content_hash, twin.catalog_entry().content_hash)
self.assertEqual(entry.members, twin.catalog_entry().members)
self.assertEqual({m.index_alias for m in entry.members if m.index_alias}, set(aliases))
def test_facts_sharing_every_role_value_are_all_admitted(self) -> None:
from cultnet_py.cultmesh_contracts import CultMeshSimulationFact, simulation_fact_document as document
cache = cache_of(document)
for number in range(3):
fact = CultMeshSimulationFact.from_candidate(
{"shardId": "s", "shardEpoch": 1, "frame": 4, "subjectId": "x", "claimKind": "k", "claimHash": f"h{number}",
"witnessCount": 1, "supportWeight": 1.0, "totalWeight": 1.0, "confidence": 1.0}
)
cache.put(document, f"simulation:{number}", fact)
self.assertEqual(len(cache.get_all(document)), 3)
self.assertIsNone(cache.get_key_by_index(document, "shard", "s"))
class VectorTests(unittest.TestCase):
def test_values_rows(self) -> None:
document = item_doc(indexes={"sku": "sku"}, roles={})
for label, kind, value, text in msgpack.unpackb((VECTORS / "values.msgpack").read_bytes(), raw=False):
with self.subTest(label=label):
self.assertEqual(index_text(value), text)
cache = cache_of(document)
cache.put(document, "item:1", Item(sku=value))
if text is None:
cache.put(document, "item:2", Item(sku=value)) # blank: two records commit
self.assertEqual(len(cache.get_all(document)), 2)
else:
self.assertEqual(cache.get_key_by_index(document, "sku", text), "item:1")
with self.assertRaises(CultCacheError):
cache.put(document, "item:2", Item(sku=value))
def test_store_verdicts(self) -> None:
unit = define_database_entry_type
outcomes = msgpack.unpackb((VECTORS / "outcomes.msgpack").read_bytes(), raw=False)
self.assertEqual(len(outcomes), 3)
for name, alias, verdict, key_a, key_b in outcomes:
for roles_first in (False, True): # the verdict does not depend on declaration order
with self.subTest(file=name, alias=alias, roles_first=roles_first), tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / name
shutil.copyfile(VECTORS / name, path)
before = path.read_bytes()
fields = [database_entry_field("sku", 0, codec=t.string), database_entry_field("category", 1, codec=t.string)]
declared = {"indexes": {alias: "sku"} if alias else {}, "roles": {} if alias else {"Sku": "sku"}}
if roles_first:
declared = dict(reversed(list(declared.items())))
document = unit(
"tests.unique_item",
fields,
schema_id="sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882",
schema_name="tests.unique_item",
**declared,
)
cache = cache_of(document, store=SingleFileMessagePackBackingStore(path))
if verdict == "open":
cache.pull_all_backing_stores()
self.assertGreaterEqual(len(cache.get_all(document)), 2)
else:
with self.assertRaises(CultCacheError) as caught:
cache.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal(key_a, key_b, "tests.unique_item", alias))
self.assertNotIn("S-1", str(caught.exception))
self.assertEqual(cache.snapshot(), {})
self.assertEqual(path.read_bytes(), before)
def test_refusals(self) -> None:
ids = {
"tests.unique_item": "sha256:93bb890de83069061af696b23f94fd32fb3c2dad3c91ee645e8add56085f1882",
"tests.uq_pair": "sha256:2538cceafb6dcbce22ef11b932ecb804af5f96bc3aaedff692a52d3640433e60",
"tests.uq_alias": "sha256:77a77a394ebd8bf71e14b9b6f2cea4978c25d4e705fd12364f660edbf6c63b1f",
"tests.uq_wide": "sha256:b79eecd11eed5e456902f095d2748da7f59125c4c8972b0da7dddc81d07fc03f",
"tests.uq_alpha": "sha256:53aeaf2ea9b25e85ade6b15fc04c7653e7b206c87e258f1aa74dd32a840a1389",
"tests.uq_beta": "sha256:45ed62a64c91f6d815d3ec975c4a4a1b2ce07b6e7262a32d828673cf10899586",
}
aliases = {
"tests.uq_pair": {"Sku": "sku", "Alt": "second"},
"tests.uq_alias": {"Sku": "sku", "alt": "second"},
"tests.uq_wide": {"\uff21": "sku", "\U00010400": "second"}, # U+FF21 and U+10400: code-unit order puts the astral alias first
}
documents = {
name: define_database_entry_type(
name,
[database_entry_field("sku", 0, codec=t.string), database_entry_field("second", 1, codec=t.string)],
indexes=aliases.get(name, {"Sku": "sku"}),
roles={},
schema_id=schema_id,
schema_name=name,
)
for name, schema_id in ids.items()
}
rows = msgpack.unpackb((VECTORS / "refusals.msgpack").read_bytes(), raw=False)
walked = 0
for label, ops, expected in rows:
if any(op[0] == "commit" and len({schema for _, schema, _, _ in op[1]}) > 1 for op in ops):
continue # put_envelopes takes one type: no batch across types (rows 3 and 26)
with self.subTest(label=label), tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "store.cc"
state: dict[str, CultCache] = {}
def perform(op) -> None:
if op[0] == "open":
if op[1] is not None:
shutil.copyfile(VECTORS / op[1], path)
state["cache"] = cache_of(*documents.values(), store=SingleFileMessagePackBackingStore(path))
state["cache"].pull_all_backing_stores()
elif op[0] == "pull":
shutil.copyfile(VECTORS / op[1], path)
state["cache"].pull_all_backing_stores()
else:
document = documents[op[1][0][1]]
state["cache"].put_envelopes(
document, [envelope(document, key, {"sku": sku, "second": second}) for key, _, sku, second in op[1]]
)
for op in ops[:-1]:
perform(op)
if expected is None:
perform(ops[-1])
else:
last = ops[-1]
if last[0] == "open":
before = (VECTORS / last[1]).read_bytes()
elif last[0] == "pull":
before = (VECTORS / last[1]).read_bytes()
else:
before = path.read_bytes() if path.exists() else b""
k1, k2, schema, alias = expected
with self.assertRaises(CultCacheError) as caught:
perform(last)
self.assertEqual(str(caught.exception), refusal(k1, k2, schema, alias))
self.assertEqual(path.read_bytes() if path.exists() else b"", before)
walked += 1
self.assertEqual(walked, 35)
class ListStore(RecordingStore):
"""A store whose contents the test swaps between loads: another process rewrote the file."""
def __init__(self, envelopes=()) -> None:
super().__init__()
self.envelopes = list(envelopes)
def pull_all(self):
return list(self.envelopes)
class BatchAndOrderTests(unittest.TestCase):
def test_a_batch_naming_a_key_twice_lands_the_last_record_and_keeps_every_held_lookup(self) -> None:
# C# CultCacheBatch stages one operation per key, the last one named.
document = item_doc()
store = RecordingStore()
cache = cache_of(document, store=store)
cache.put(document, "item:h", Item(sku="S"))
store.pushed.clear()
cache.put_envelopes(
document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))]
)
self.assertEqual(store.pushed, ["item:k"])
self.assertEqual(cache.get_required(document, "item:k").sku, "T")
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:h")
self.assertEqual(cache.get_key_by_index(document, "sku", "T"), "item:k")
with self.assertRaises(CultCacheError) as caught:
cache.put(document, "item:z", Item(sku="S"))
self.assertEqual(str(caught.exception), refusal("item:h", "item:z", "item", "sku"))
def test_a_batch_naming_a_key_twice_is_judged_on_the_record_that_lands(self) -> None:
document = item_doc()
cache = cache_of(document)
cache.put(document, "item:h", Item(sku="S"))
# the first record for k would collide with h; only the last lands, so the batch is admitted
cache.put_envelopes(
document, [envelope(document, "item:k", Item(sku="S")), envelope(document, "item:k", Item(sku="T"))]
)
# and the reverse order lands the colliding record, which is refused
with self.assertRaises(CultCacheError):
cache.put_envelopes(
document, [envelope(document, "item:j", Item(sku="U")), envelope(document, "item:j", Item(sku="S"))]
)
self.assertNotIn("item:j", cache.snapshot().get("item", {}))
def test_a_load_judges_every_type_at_once_aliases_in_ordinal_order(self) -> None:
first = item_doc("first", indexes={"zeta": "sku"}, roles={})
second = item_doc("second", indexes={"alpha": "sku"}, roles={})
loose_first, loose_second = item_doc("first", indexes={}, roles={}), item_doc("second", indexes={}, roles={})
store = ListStore(
[envelope(loose_first, "f:1", Item(sku="S")), envelope(loose_first, "f:2", Item(sku="S"))]
+ [envelope(loose_second, "s:1", Item(sku="S")), envelope(loose_second, "s:2", Item(sku="S"))]
)
cache = cache_of(first, second, store=store)
with self.assertRaises(CultCacheError) as caught:
cache.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal("s:1", "s:2", "second", "alpha"))
def test_aliases_sort_by_utf16_code_unit_not_code_point(self) -> None:
astral, bmp = "k\U0001F600", "k\uff01" # code points: bmp < astral; UTF-16 units: D83D < FF01
document = item_doc(indexes={bmp: "sku", astral: "serial"}, roles={})
cache = cache_of(document)
rows = [Item(sku="S", serial="T"), Item(sku="S", serial="T")]
with self.assertRaises(CultCacheError) as caught:
cache.put_envelopes(document, [envelope(document, f"item:{n}", row) for n, row in enumerate(rows)])
self.assertEqual(str(caught.exception), refusal("item:0", "item:1", "item", astral))
def test_a_load_names_the_least_violation_whatever_order_the_store_yields(self) -> None:
alpha, beta = item_doc("alpha", indexes={"k": "sku"}, roles={}), item_doc("beta", indexes={"k": "sku"}, roles={})
loose_alpha, loose_beta = item_doc("alpha", indexes={}, roles={}), item_doc("beta", indexes={}, roles={})
store = ListStore(
[
envelope(loose_alpha, "a:0", Item(sku="Q")),
envelope(loose_beta, "b:1", Item(sku="S")),
envelope(loose_beta, "b:2", Item(sku="S")),
envelope(loose_alpha, "a:1", Item(sku="P")),
envelope(loose_alpha, "a:2", Item(sku="P")),
]
)
cache = cache_of(alpha, beta, store=store)
with self.assertRaises(CultCacheError) as caught:
cache.pull_all_backing_stores()
self.assertEqual(str(caught.exception), refusal("a:1", "a:2", "alpha", "k"))
def test_register_index_refuses_an_alias_the_definition_declares_as_a_role(self) -> None:
document = item_doc()
cache = cache_of(document)
with self.assertRaises(CultCacheError):
cache.register_index(document, "group", "group")
cache.put_envelopes(
document, [envelope(document, "item:1", Item(group="guide")), envelope(document, "item:2", Item(group="guide"))]
)
class LoadFreshnessTests(unittest.TestCase):
def test_a_load_is_judged_on_the_file_alone_not_against_the_held_map(self) -> None:
document = item_doc()
store = ListStore([envelope(document, "item:b", Item(sku="S")), envelope(document, "item:c", Item(sku="T"))])
cache = cache_of(document, store=store)
cache.pull_all_backing_stores()
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:b")
# another process moved S from b to a, and b to T's old place
store.envelopes = [envelope(document, "item:a", Item(sku="S")), envelope(document, "item:b", Item(sku="U")), store.envelopes[1]]
cache.pull_all_backing_stores()
self.assertEqual(cache.get_key_by_index(document, "sku", "S"), "item:a")
self.assertEqual(cache.get_key_by_index(document, "sku", "U"), "item:b")
class DefinitionTests(unittest.TestCase):
def test_every_constructor_refuses_an_alias_in_both_indexes_and_roles(self) -> None:
from dataclasses import replace
from cultcache_py import DocumentDefinition
with self.assertRaisesRegex(ValueError, "both an index and a role"):
DocumentDefinition(type="item", indexes={"sku": "sku"}, roles={"sku": "sku"})
with self.assertRaisesRegex(ValueError, "both an index and a role"):
replace(item_doc(roles={}), roles={"sku": "sku"})
if __name__ == "__main__":
unittest.main()

View file

@ -1,731 +0,0 @@
"""CultCache.update (variants:cut_spec:cut-update-with-retry-py): read the record as its home store holds it, apply a function,
commit only if the record is unchanged, run the function again on a lost race. Mirrors C# UpdateTests and the TS update tests."""
from __future__ import annotations
import shutil
import tempfile
import threading
import time
import unittest
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
from cultcache_py import (
CultCache,
CultCacheEnvelope,
JsonLinesBackingStore,
SchemaConflictError,
SingleFileMessagePackBackingStore,
UpdateOutcome,
UpdateResult,
database_entry_field,
define_database_entry_type,
define_document_type,
leaves as t,
)
from cultcache_py.cache import GLOBAL_KEY, CultCacheError
from cultcache_py.stores import parse_stored_at, mint_stored_at, record_version, stored_at_over
@dataclass
class Item:
name: str = "n"
value: int = 0
sku: str | None = None
def _doc(type: str, **extra):
return define_document_type(type, encode=asdict, decode=lambda raw: Item(**raw), **extra)
item_doc = _doc("item")
other_doc = _doc("other")
sku_doc = _doc("skuitem", indexes={"sku": "sku"})
global_doc = _doc("gitem", global_document=True)
@dataclass
class Wide:
name: str = ""
extra: str = ""
@dataclass
class Narrow:
name: str = ""
ALPHA = "2099-01-01T00:00:00.0000000Z"
class Fixture(unittest.TestCase):
def setUp(self) -> None:
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.path = self.dir / "state.cc"
def cache(self, *documents, path: Path | None = None, store=True) -> CultCache:
builder = CultCache.builder()
for document in documents or (item_doc,):
builder.register_document_type(document)
if store:
builder.add_generic_store(SingleFileMessagePackBackingStore(path or self.path))
cache = builder.build()
if store:
cache.pull_all_backing_stores()
return cache
def stored(self, key: str = "k", document=item_doc):
"""What a fresh cache reads at the key."""
return self.cache(document).get(document, key)
def bytes(self) -> bytes | None:
return self.path.read_bytes() if self.path.exists() else None
class Counting:
"""An apply that records what it was given."""
def __init__(self, fn=None) -> None:
self.seen: list = []
self.fn = fn or (lambda item, calls: Item("n", (item.value if item else 0) + 1))
def __call__(self, item):
self.seen.append(None if item is None else item.value)
# An always-losing apply must fail by assertion when the attempt bound is gone, not hang.
assert len(self.seen) <= 10, "apply ran past the attempt bound"
return self.fn(item, len(self.seen))
@property
def calls(self) -> int:
return len(self.seen)
class UpdateTests(Fixture):
def test_two_writers_updating_lose_no_increment(self) -> None:
results: list[UpdateResult] = []
calls = [0]
lock = threading.Lock()
a, b = self.cache(), self.cache()
a.put(item_doc, "k", Item("n", 0))
def bump(item):
with lock:
calls[0] += 1
time.sleep(0)
return Item("n", item.value + 1)
def run(cache) -> None:
for _ in range(50):
results.append(cache.update(item_doc, "k", bump, max_attempts=200))
threads = [threading.Thread(target=run, args=(cache,)) for cache in (a, b)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertTrue(all(result.outcome is UpdateOutcome.UPDATED for result in results))
self.assertEqual(self.stored().value, 100)
self.assertGreater(calls[0], 100)
def test_a_lost_attempt_reruns_apply_on_the_fresh_value(self) -> None:
cache, rival = self.cache(), self.cache()
cache.put(item_doc, "k", Item("n", 1))
rival.pull_all_backing_stores()
apply = Counting(lambda item, calls: (rival.put(item_doc, "k", Item("n", 100)) if calls == 1 else None) or Item("n", item.value + 1))
result = cache.update(item_doc, "k", apply)
self.assertEqual(apply.seen, [1, 100])
self.assertIs(result.outcome, UpdateOutcome.UPDATED)
self.assertEqual(result.document.value, 101)
self.assertEqual(self.stored().value, 101)
def test_exhausted_attempts_return_lost_race(self) -> None:
cache, rival = self.cache(), self.cache()
cache.put(item_doc, "k", Item("n", 1))
rival.pull_all_backing_stores()
apply = Counting(lambda item, calls: rival.put(item_doc, "k", Item("n", 100 * calls)) or Item("n", -1))
result = cache.update(item_doc, "k", apply, max_attempts=3)
self.assertEqual(apply.calls, 3)
self.assertIs(result.outcome, UpdateOutcome.LOST_RACE)
self.assertEqual(result.document.value, 300) # the pull before LOST_RACE shows the winner
self.assertEqual(self.stored().value, 300)
def test_max_attempts_bounds(self) -> None:
cache, rival = self.cache(), self.cache()
cache.put(item_doc, "k", Item("n", 1))
rival.pull_all_backing_stores()
def always_losing(item, calls):
rival.put(item_doc, "k", Item("n", 100 + calls))
return Item("n", -1)
apply = Counting(always_losing)
result = cache.update(item_doc, "k", apply, max_attempts=1)
self.assertEqual((apply.calls, result.outcome), (1, UpdateOutcome.LOST_RACE))
for bad, error in ((0, ValueError), (-1, ValueError), (True, TypeError), (1.5, TypeError), (None, TypeError)):
with self.subTest(max_attempts=bad):
untouched = Counting()
with self.assertRaises(error):
cache.update(item_doc, "k", untouched, max_attempts=bad)
self.assertEqual(untouched.calls, 0)
with self.assertRaises(TypeError):
cache.update(item_doc, "k", "not callable")
with self.assertRaises(TypeError):
cache.update(item_doc, "k", None)
def test_apply_gets_a_fresh_copy(self) -> None:
cache, rival = self.cache(), self.cache()
cache.put(item_doc, "k", Item("n", 1))
rival.pull_all_backing_stores()
arguments: list[Item] = []
def apply(item):
arguments.append(item)
self.assertIsNot(item, cache.get(item_doc, "k"))
if len(arguments) == 1:
item.value = 777 # changing the copy in place changes nothing the cache or the next attempt sees
rival.put(item_doc, "k", Item("n", 5))
self.assertEqual(cache.get(item_doc, "k").value, 1)
return Item("n", item.value + 1)
result = cache.update(item_doc, "k", apply)
self.assertEqual([item.value for item in arguments], [777, 5])
self.assertIsNot(arguments[0], arguments[1])
self.assertEqual(result.document.value, 6)
def test_an_in_place_change_to_the_held_value_is_neither_applied_nor_persisted(self) -> None:
cache = self.cache()
cache.put(item_doc, "k", Item("n", 1))
cache.get(item_doc, "k").value = 999
apply = Counting(lambda item, calls: Item("n", item.value + 1))
result = cache.update(item_doc, "k", apply)
self.assertEqual(apply.seen, [1])
self.assertEqual(self.stored().value, 2)
result.document.value = 999
cache.update(item_doc, "k", apply)
self.assertEqual(apply.seen, [1, 2])
self.assertEqual(self.stored().value, 3)
bare = self.cache(store=False) # a cache with no store copies its held instance
bare.put(item_doc, "k", Item("n", 1))
bare.get(item_doc, "k").value = 999
seen = Counting(lambda item, calls: Item("n", item.value + 1))
self.assertEqual(bare.update(item_doc, "k", seen).document.value, 2)
self.assertEqual(seen.seen, [1])
def test_none_declines(self) -> None:
cache = self.cache()
cache.put(item_doc, "k", Item("n", 1))
before = self.bytes()
result = cache.update(item_doc, "k", lambda item: None)
self.assertIs(result.outcome, UpdateOutcome.DECLINED)
self.assertEqual(result.document, cache.get(item_doc, "k"))
self.assertEqual(self.bytes(), before)
absent = cache.update(item_doc, "missing", lambda item: None)
self.assertEqual((absent.outcome, absent.document), (UpdateOutcome.DECLINED, None))
self.assertEqual(self.bytes(), before)
empty = Fixture.cache(self, path=self.dir / "empty.cc")
self.assertEqual(empty.update(item_doc, "k", lambda item: None).outcome, UpdateOutcome.DECLINED)
self.assertFalse((self.dir / "empty.cc").exists())
def test_a_declined_or_lost_attempt_installs_nothing(self) -> None:
cache, rival = self.cache(), self.cache()
cache.put(item_doc, "k", Item("n", 1))
rival.pull_all_backing_stores()
rival.put(item_doc, "k", Item("n", 5))
self.assertIs(cache.update(item_doc, "k", lambda item: None).outcome, UpdateOutcome.DECLINED)
self.assertEqual(cache.get(item_doc, "k").value, 1)
cache.pull_all_backing_stores()
self.assertEqual(cache.get(item_doc, "k").value, 5)
during: list[int] = []
def apply(item, calls):
during.append(cache.get(item_doc, "k").value)
if calls == 1:
rival.put(item_doc, "k", Item("n", 9))
return Item("n", item.value + 1)
cache.update(item_doc, "k", Counting(apply))
self.assertEqual(during, [5, 5]) # the lost attempt published nothing mid-update
def test_absent_key_rival_removal_and_a_store_that_holds_nothing(self) -> None:
a, b = self.cache(), self.cache()
bump = lambda item: Item("n", (item.value if item else 0) + 1) # noqa: E731
self.assertIs(a.update(item_doc, "k", bump).outcome, UpdateOutcome.UPDATED)
self.assertIs(b.update(item_doc, "k", bump).outcome, UpdateOutcome.UPDATED)
self.assertEqual(self.stored().value, 2)
cache, rival = self.cache(), self.cache()
cache.put(item_doc, "d", Item("n", 1))
rival.pull_all_backing_stores()
apply = Counting(lambda item, calls: (rival.delete(item_doc, "d") if calls == 1 else None) or Item("n", (item.value if item else 0) + 1))
cache.update(item_doc, "d", apply)
self.assertEqual(apply.seen, [1, None])
self.assertEqual(self.stored("d").value, 1)
holder = self.cache()
holder.put(item_doc, "h", Item("n", 3))
rival.pull_all_backing_stores()
rival.delete(item_doc, "h")
nothing = Counting(lambda item, calls: Item("n", 1))
holder.update(item_doc, "h", nothing)
self.assertEqual(nothing.seen, [None])
def test_a_rival_writing_and_restoring_the_value_still_reruns_apply(self) -> None:
cache, rival = self.cache(), self.cache()
cache.put(item_doc, "k", Item("n", 1))
rival.pull_all_backing_stores()
def apply(item, calls):
if calls == 1:
rival.put(item_doc, "k", Item("n", 5))
rival.put(item_doc, "k", Item("n", 1))
return Item("n", item.value + 1)
counting = Counting(apply)
cache.update(item_doc, "k", counting)
self.assertEqual(counting.seen, [1, 1])
self.assertEqual(self.stored().value, 2)
def test_a_stale_caches_first_attempt_reads_the_store(self) -> None:
a, b = self.cache(), self.cache()
a.put(item_doc, "k", Item("n", 1))
b.pull_all_backing_stores()
a.put(item_doc, "k", Item("n", 7))
apply = Counting(lambda item, calls: Item("n", item.value + 1))
b.update(item_doc, "k", apply)
self.assertEqual(apply.seen, [7])
self.assertEqual(self.stored().value, 8)
class UpdateRefusalTests(Fixture):
def refused(self, cache: CultCache, key: str = "k", document=item_doc, *errors) -> None:
before = self.bytes()
apply = Counting()
with self.assertRaises(errors or (CultCacheError, SchemaConflictError)):
cache.update(document, key, apply)
self.assertEqual(apply.calls, 0)
self.assertEqual(self.bytes(), before)
def test_refusals_before_apply(self) -> None:
writer = self.cache(item_doc, other_doc)
writer.put(other_doc, "k", Item("n", 1))
self.refused(writer) # the record at the key is another document's, held
self.refused(self.fresh(item_doc, other_doc)) # unpulled: the store's record is another document's
self.refused(self.cache(item_doc)) # foreign: the store holds a type this cache never registered
self.refused(self.fresh(item_doc))
storeless = self.cache(item_doc, other_doc, store=False)
storeless.put(other_doc, "k", Item("n", 1))
apply = Counting()
with self.assertRaises((CultCacheError, SchemaConflictError)):
storeless.update(item_doc, "k", apply)
self.assertEqual(apply.calls, 0)
def fresh(self, *documents) -> CultCache:
"""A cache that has not pulled: whatever it reads, it reads from the store at update."""
builder = CultCache.builder()
for document in documents:
builder.register_document_type(document)
return builder.add_generic_store(SingleFileMessagePackBackingStore(self.path)).build()
def test_a_home_without_read_and_commit_if_refuses(self) -> None:
jsonl = JsonLinesBackingStore(self.dir / "state.jsonl")
cache = CultCache.builder().register_document_type(item_doc).add_generic_store(jsonl).build()
cache.pull_all_backing_stores()
cache.put(item_doc, "k", Item("n", 1))
before = (self.dir / "state.jsonl").read_bytes()
apply = Counting()
with self.assertRaisesRegex(CultCacheError, "cannot write conditionally"):
cache.update(item_doc, "k", apply)
self.assertEqual(apply.calls, 0)
self.assertEqual((self.dir / "state.jsonl").read_bytes(), before)
self.assertFalse(hasattr(JsonLinesBackingStore, "read") or hasattr(JsonLinesBackingStore, "commit_if"))
class Bare:
def pull_all(self):
return []
def push(self, envelope):
raise AssertionError("update wrote through a store that cannot write conditionally")
def push_all(self, envelopes, soft=False):
raise AssertionError("push_all")
def delete(self, type, key):
raise AssertionError("delete")
bare = CultCache.builder().register_document_type(item_doc).add_generic_store(Bare()).build()
with self.assertRaisesRegex(CultCacheError, "Bare"):
bare.update(item_doc, "k", apply)
self.assertEqual(apply.calls, 0)
def test_read_only_records_refuse_before_apply(self) -> None:
wide = define_database_entry_type(
"tests.rw",
[database_entry_field("name", 0, codec=t.string), database_entry_field("extra", 1, codec=t.string)],
cls=Wide,
schema_id="tests.rw.v1",
)
narrow = define_database_entry_type("tests.rw", [database_entry_field("name", 0, codec=t.string)], cls=Narrow, schema_id="tests.rw.v1")
self.cache(wide).put(wide, "k", Wide("n", "x"))
before = self.bytes()
for pulled in (True, False): # held read-only, then a store record the cache never loaded
with self.subTest(pulled=pulled):
cache = self.cache(narrow) if pulled else self.fresh(narrow)
apply = Counting()
with self.assertRaises(SchemaConflictError) as caught:
cache.update(narrow, "k", apply)
self.assertEqual(caught.exception.record_key, "k")
self.assertEqual(apply.calls, 0)
self.assertEqual(self.bytes(), before)
def test_a_non_global_key_for_a_global_document_refuses(self) -> None:
cache = self.cache(global_doc)
apply = Counting()
with self.assertRaises(CultCacheError):
cache.update(global_doc, "not-global", apply)
self.assertEqual(apply.calls, 0)
def test_refusals_after_apply_are_not_retried(self) -> None:
cache = self.cache()
cache.put(item_doc, "k", Item("n", 1))
before = self.bytes()
boom = Counting(lambda item, calls: (_ for _ in ()).throw(RuntimeError("boom")))
with self.assertRaisesRegex(RuntimeError, "boom"):
cache.update(item_doc, "k", boom)
self.assertEqual(boom.calls, 1)
self.assertEqual(self.bytes(), before)
indexed = self.cache(sku_doc, path=self.dir / "sku.cc")
indexed.put(sku_doc, "a", Item("n", 1, "X"))
indexed.put(sku_doc, "b", Item("n", 1, "Y"))
before = (self.dir / "sku.cc").read_bytes()
clash = Counting(lambda item, calls: Item("n", 2, "X"))
with self.assertRaisesRegex(CultCacheError, "unique index"):
indexed.update(sku_doc, "b", clash)
self.assertEqual(clash.calls, 1)
self.assertEqual((self.dir / "sku.cc").read_bytes(), before)
class UpdateOwnWritesTests(Fixture):
def test_own_writes_and_the_held_instance(self) -> None:
cache = self.cache()
cache.put(item_doc, "k", Item("n", 1))
apply = Counting(lambda item, calls: Item("n", item.value + 1))
cache.update(item_doc, "k", apply)
second = cache.update(item_doc, "k", apply)
self.assertEqual(apply.calls, 2)
self.assertIs(second.document, cache.get(item_doc, "k"))
self.assertEqual(second.document.value, 3)
def sideways(item, calls):
cache.put(item_doc, "side", Item("n", 5)) # apply runs outside the cache's hold
return Item("n", item.value + 1)
sideways_apply = Counting(sideways)
result = cache.update(item_doc, "k", sideways_apply)
self.assertEqual((result.outcome, sideways_apply.calls), (UpdateOutcome.UPDATED, 1))
self.assertEqual(self.stored("side").value, 5)
def test_store_less_cache(self) -> None:
cache = self.cache(store=False)
cache.put(item_doc, "k", Item("n", 0))
results: list[UpdateResult] = []
def bump(item):
time.sleep(0)
return Item("n", item.value + 1)
def run() -> None:
for _ in range(50):
results.append(cache.update(item_doc, "k", bump, max_attempts=500))
threads = [threading.Thread(target=run) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertTrue(all(result.outcome is UpdateOutcome.UPDATED for result in results))
self.assertEqual(cache.get(item_doc, "k").value, 100)
# A store that becomes the type's home while apply runs holds a record the cache never read: the commit loses.
cache = self.cache(store=False) # holds no record of the type, so a store may become its home
other = self.cache(path=self.dir / "late.cc")
other.put(item_doc, "fresh", Item("n", 40))
late = SingleFileMessagePackBackingStore(self.dir / "late.cc")
def attach(item, calls):
if calls == 1:
cache.add_generic_store(late)
return Item("n", (item.value if item else 0) + 1)
apply = Counting(attach)
result = cache.update(item_doc, "fresh", apply)
self.assertEqual(apply.seen, [None, 40])
self.assertIs(result.outcome, UpdateOutcome.UPDATED)
self.assertEqual(self.cache(path=self.dir / "late.cc").get(item_doc, "fresh").value, 41)
def test_a_home_attached_while_apply_runs_is_reread_or_refused(self) -> None:
"""The cache's home for the type changes between the read and the commit (attaching is allowed while it holds no record
of the type): the version read from the old home is no condition on the new one."""
cache = self.cache(path=self.dir / "generic.cc")
late = SingleFileMessagePackBackingStore(self.dir / "late.cc")
def attach(item, calls):
if calls == 1:
cache.add_backing_store(late, ["item"])
return Item("n", 1)
apply = Counting(attach)
self.assertIs(cache.update(item_doc, "k", apply).outcome, UpdateOutcome.UPDATED)
self.assertEqual(apply.seen, [None, None]) # reread from the new home, not committed with the old home's version
self.assertEqual(self.cache(path=self.dir / "late.cc").get(item_doc, "k").value, 1)
cache = self.cache(path=self.dir / "generic2.cc")
jsonl = JsonLinesBackingStore(self.dir / "late.jsonl")
def attach_jsonl(item, calls):
cache.add_backing_store(jsonl, ["item"])
return Item("n", 1)
apply = Counting(attach_jsonl)
with self.assertRaisesRegex(CultCacheError, "cannot write conditionally"):
cache.update(item_doc, "k", apply)
self.assertEqual(apply.calls, 1)
self.assertFalse((self.dir / "late.jsonl").exists())
def test_ledger_under_contention(self) -> None:
caches = [self.cache() for _ in range(3)]
outcomes: list[UpdateOutcome] = []
def run(cache) -> None:
for _ in range(25):
outcomes.append(cache.update(item_doc, "k", lambda item: Item("n", (item.value if item else 0) + 1)).outcome)
threads = [threading.Thread(target=run, args=(cache,)) for cache in caches]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(self.stored().value, outcomes.count(UpdateOutcome.UPDATED))
self.assertEqual(len(outcomes), 75)
class UpdateLegacyGlobalTests(Fixture):
def plant(self, key: str = "legacy") -> None:
entry = global_doc.catalog_entry()
SingleFileMessagePackBackingStore(self.path).push(
CultCacheEnvelope.create(key=key, type=global_doc.type, payload=global_doc.encode_payload(Item("n", 1)), schema_id=entry.schema_id, catalog_entry=entry)
)
def test_update_global_updates_the_global_record(self) -> None:
cache = self.cache(global_doc)
result = cache.update_global(global_doc, lambda item: Item("n", (item.value if item else 0) + 1))
self.assertIs(result.outcome, UpdateOutcome.UPDATED)
self.assertEqual(self.cache(global_doc).get(global_doc, GLOBAL_KEY).value, 1)
def test_a_legacy_global_is_refused_until_put_global_retires_it(self) -> None:
self.plant()
cache = self.cache(global_doc) # loaded from the legacy key
before = self.bytes()
for call in (lambda a: cache.update_global(global_doc, a), lambda a: cache.update(global_doc, GLOBAL_KEY, a)):
apply = Counting()
with self.assertRaisesRegex(CultCacheError, "put_global"):
call(apply)
self.assertEqual(apply.calls, 0)
self.assertEqual(self.bytes(), before)
# Judged from the cache's load, not only from the store: the legacy record is gone from the store, the cache still holds it.
SingleFileMessagePackBackingStore(self.path).delete(global_doc.type, "legacy")
apply = Counting()
with self.assertRaisesRegex(CultCacheError, "put_global"):
cache.update_global(global_doc, apply)
self.assertEqual(apply.calls, 0)
cache.put_global(global_doc, Item("n", 5))
self.assertEqual(cache.update_global(global_doc, lambda item: Item("n", item.value + 1)).document.value, 6)
def test_a_pull_inside_apply_that_loads_a_legacy_global_fails_the_commit(self) -> None:
cache = self.cache(global_doc)
def apply(item):
self.plant()
cache.pull_all_backing_stores()
return Item("n", 9)
with self.assertRaisesRegex(CultCacheError, "put_global"):
cache.update_global(global_doc, apply)
self.assertEqual([envelope.key for envelope in SingleFileMessagePackBackingStore(self.path).pull_all()], ["legacy"])
def test_an_unpulled_cache_judges_the_legacy_global_from_the_store(self) -> None:
self.plant()
before = self.bytes()
cache = SingleFileMessagePackBackingStore(self.path)
unpulled = CultCache.builder().register_document_type(global_doc).add_generic_store(cache).build()
apply = Counting()
with self.assertRaisesRegex(CultCacheError, "put_global"):
unpulled.update_global(global_doc, apply)
self.assertEqual(apply.calls, 0)
self.assertEqual(self.bytes(), before)
class StoreRestampTests(Fixture):
def envelope(self, value: int, stored_at: str = ALPHA, key: str = "k") -> CultCacheEnvelope:
entry = item_doc.catalog_entry()
return CultCacheEnvelope(key, "item", item_doc.encode_payload(Item("n", value)), stored_at, entry.schema_id, entry)
def stamp_on_disk(self, store, key: str = "k") -> str:
return next(envelope.stored_at for envelope in store.pull_all() if envelope.key == key)
def later_than_alpha(self, text: str) -> bool:
return parse_stored_at(text) > parse_stored_at(ALPHA)
def test_commit_if_and_the_restamp_at_the_store(self) -> None:
store = SingleFileMessagePackBackingStore(self.path)
self.assertIsNone(store.read("k"))
stamp = store.commit_if(self.envelope(1), None) # None against an absent key commits
self.assertEqual(stamp, ALPHA)
envelope, version = store.read("k")
self.assertEqual(version, record_version(envelope))
before = self.bytes()
self.assertIsNone(store.commit_if(self.envelope(2, "2100-01-01T00:00:00Z"), None)) # a record is there: not absent
self.assertIsNone(store.commit_if(self.envelope(2, "2100-01-01T00:00:00Z"), "0" * 64))
self.assertEqual(self.bytes(), before)
written = store.commit_if(self.envelope(2), version) # a staged stamp that is no later is minted over
self.assertTrue(self.later_than_alpha(written))
self.assertEqual(self.stamp_on_disk(store), written)
self.assertEqual(stamp_of(store, "k"), written)
def test_every_replacing_write_is_stored_strictly_later(self) -> None:
for make in (SingleFileMessagePackBackingStore, JsonLinesBackingStore):
for name in ("push", "push_all", "commit_if"):
if name == "commit_if" and make is JsonLinesBackingStore:
continue
with self.subTest(store=make.__name__, write=name):
path = self.dir / f"{make.__name__}-{name}"
store = make(path)
store.push(self.envelope(1))
if name == "push":
reported = store.push(self.envelope(2))
elif name == "push_all":
reported = store.push_all([self.envelope(2)])["k"]
else:
reported = store.commit_if(self.envelope(2), store.read("k")[1])
self.assertTrue(self.later_than_alpha(reported))
self.assertEqual(self.stamp_on_disk(store), reported)
def test_the_cache_holds_the_stamp_the_store_wrote(self) -> None:
for name in ("put", "put_envelope", "put_envelopes", "update"):
with self.subTest(write=name):
path = self.dir / name
SingleFileMessagePackBackingStore(path).push(self.envelope(1))
cache = self.cache(path=path)
self.assertEqual(cache.get_envelope(item_doc, "k").stored_at, ALPHA)
if name == "put":
cache.put(item_doc, "k", Item("n", 2))
elif name == "put_envelope":
cache.put_envelope(item_doc, self.envelope(2))
elif name == "put_envelopes":
cache.put_envelopes(item_doc, [self.envelope(2)])
else:
cache.update(item_doc, "k", lambda item: Item("n", 2))
held = cache.get_envelope(item_doc, "k").stored_at
self.assertTrue(self.later_than_alpha(held))
self.assertEqual(held, self.stamp_on_disk(SingleFileMessagePackBackingStore(path)))
def test_a_store_less_cache_stamps_a_replacing_write_later(self) -> None:
cache = self.cache(store=False)
cache.put_envelope(item_doc, self.envelope(1))
cache.put_envelope(item_doc, self.envelope(2, "2000-01-01T00:00:00Z"))
self.assertTrue(self.later_than_alpha(cache.get_envelope(item_doc, "k").stored_at))
def test_only_the_stamp_shape_the_runtimes_write_parses(self) -> None:
"""An RFC 3339 date-time with T, a fraction of 1 to 9 digits and Z or a numeric offset, on every interpreter (the shape is
checked here, not by fromisoformat), so a stamp reads the same on 3.10 and 3.13. The edges follow RFC 3339: offset hours 00-23,
minutes 00-59."""
utc = timezone.utc
accepted = {
"2026-01-01T00:00:00Z": datetime(2026, 1, 1, tzinfo=utc),
"2026-01-01T00:00:00.1Z": datetime(2026, 1, 1, 0, 0, 0, 100000, utc),
"2026-01-01T00:00:00.123456789Z": datetime(2026, 1, 1, 0, 0, 0, 123456, utc),
"2026-01-01T00:00:00.1234567+00:00": datetime(2026, 1, 1, 0, 0, 0, 123456, utc),
"2026-01-01T05:30:00+05:30": datetime(2026, 1, 1, tzinfo=utc),
"2025-12-31T19:00:00-05:00": datetime(2026, 1, 1, tzinfo=utc),
"2026-01-01T23:59:00+23:59": datetime(2026, 1, 1, 0, 0, tzinfo=utc),
}
for text, instant in accepted.items():
with self.subTest(text=text):
self.assertEqual(parse_stored_at(text), instant)
for text in (
"2026-01-01", "20260101T000000Z", "2026-W01-1T00:00:00Z", "2026-01-01 00:00:00Z", "2026-01-01T00:00:00,5Z",
"2026-01-01T00Z", "2026-01-01T00:00:00", "2026-01-01T00:00:00.Z", "2026-01-01T00:00:00.1234567890Z",
"2026-01-01T00:00:00z", " 2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z ", "2026-01-01T24:00:00Z",
"2026-13-01T00:00:00Z", "2026-01-01T00:00:00+24:00", "2026-01-01T00:00:00+05:60", "2026-01-01T00:00:00+05:75", "2026-01-01T00:00Z", "2026-01-01T00:00+00:00",
"026-01-01T00:00:00Z", "12026-01-01T00:00:00Z", "2026-01-01T00:00:00+5:00", "2026-01-01T00:00:00+0000", "\u0662026-01-01T00:00:00Z",
"", "garbage",
):
with self.subTest(text=text):
self.assertIsNone(parse_stored_at(text))
def test_a_stamp_outside_the_range_is_unparseable_and_one_at_the_edge_refuses_a_later_stamp(self) -> None:
for text in ("9999-12-31T23:30:00-05:00", "0001-01-01T00:00:00+05:00"):
with self.subTest(text=text):
self.assertIsNone(parse_stored_at(text))
now = datetime(2030, 1, 1, tzinfo=timezone.utc)
self.assertEqual(mint_stored_at("9999-12-31T23:30:00-05:00", now), "2030-01-01T00:00:00.000000+00:00")
edge = "9999-12-31T23:59:59.9999999Z"
with self.assertRaisesRegex(ValueError, "last instant"):
mint_stored_at(edge, now)
with self.assertRaisesRegex(ValueError, "last instant"):
stored_at_over("2000-01-01T00:00:00Z", edge)
# A write over a record stamped at the edge is refused by the store and writes nothing, from push, commit_if and update.
store = SingleFileMessagePackBackingStore(self.path)
entry = item_doc.catalog_entry()
record = lambda value, at: CultCacheEnvelope("k", "item", item_doc.encode_payload(Item("n", value)), at, entry.schema_id, entry) # noqa: E731
store.push(record(1, edge))
before = self.bytes()
version = store.read("k")[1]
for write in (lambda: store.push(record(2, ALPHA)), lambda: store.push_all([record(2, ALPHA)]), lambda: store.commit_if(record(2, ALPHA), version)):
with self.assertRaises(ValueError):
write()
cache = self.cache()
apply = Counting()
with self.assertRaises(ValueError):
cache.update(item_doc, "k", apply)
self.assertEqual(apply.calls, 1)
self.assertEqual(self.bytes(), before)
self.assertEqual(cache.get_envelope(item_doc, "k").stored_at, edge)
def test_stamp_parsing_and_install(self) -> None:
for text in ("2026-01-01T00:00:00.1234567+00:00", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00.123456+00:00"):
with self.subTest(text=text):
self.assertIsNotNone(parse_stored_at(text))
self.assertEqual(parse_stored_at(text).tzinfo, timezone.utc)
self.assertIsNone(parse_stored_at("garbage"))
self.assertIsNone(parse_stored_at(None))
now = datetime(2030, 1, 1, tzinfo=timezone.utc)
self.assertEqual(mint_stored_at("garbage", now), "2030-01-01T00:00:00.000000+00:00")
self.assertEqual(stored_at_over("2000-01-01T00:00:00Z", "2000-01-01T00:00:00Z")[:4], str(datetime.now(timezone.utc).year)) # no later: minted at now
self.assertEqual(mint_stored_at("2030-01-01T00:00:00Z", now), "2030-01-01T00:00:00.000001+00:00") # equal is not later
self.assertEqual(mint_stored_at("2099-01-01T00:00:00.1234567Z", now), "2099-01-01T00:00:00.123457+00:00")
self.assertEqual(stored_at_over("2000-01-01T00:00:00Z", "2099-01-01T00:00:00.1234567Z"), "2099-01-01T00:00:00.123457+00:00")
for reported in (True, 1, "garbage"):
with self.subTest(reported=reported):
class Lying(SingleFileMessagePackBackingStore):
def push(self, envelope):
super().push(envelope)
return reported
cache = CultCache.builder().register_document_type(item_doc).add_generic_store(Lying(self.dir / f"lying-{reported}")).build()
cache.pull_all_backing_stores()
cache.put_envelope(item_doc, self.envelope(1, "2026-02-02T00:00:00.000000+00:00"))
self.assertEqual(cache.get_envelope(item_doc, "k").stored_at, "2026-02-02T00:00:00.000000+00:00")
def stamp_of(store, key: str) -> str:
return store.read(key)[0].stored_at
if __name__ == "__main__":
unittest.main()

View file

@ -1 +1,2 @@
/target
Cargo.lock

View file

@ -1,61 +0,0 @@
# Changelog
All notable changes to `cultcache-rs` are recorded here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Earlier releases are recorded
only in git history.
## Unreleased
### Added
- `CultCache::update_with_attempts` and the conditional `update`: each attempt reads the record from its home store with its content version in one read, applies a function to a copy and commits only over that version, rerunning on a lost race (default five attempts). The result is `CultUpdateResult<T>`, outcome `Updated`, `Declined` (the function returned `None`) or `LostRace`, with the cache's document. Public types `CultStoredRecord`, `CultCommitIf`, `CultUpdateOutcome`, `CultUpdateResult`.
- `CacheBackingStore::read(key)` and `commit_if(entry, expected)`, provided methods whose defaults refuse with an error naming the store kind; `SingleFileMessagePackBackingStore` implements both. The content version is the lowercase hex SHA-256 of the record re-encoded as the store file writes it (key, schema id, `stored_at`, payload). It equals the SHA-256 of the record's bytes in the file, and so the C# reference's `tests/vectors/content-version`, whenever the file's MessagePack encodings are minimal, which every runtime's writer produces; a hand-edited file with a non-minimal encoding (for example a `str8` key that fits a fixstr) hashes differently from its slice here. New dependency `sha2` 0.10 and eight new lockfile crates: `sha2`, `digest`, `block-buffer`, `crypto-common`, `generic-array`, `typenum`, `cpufeatures`, `version_check`.
### Changed
- `is_stored_at_stamp(&str)` (new, public) is the one shape a peer's `storedAt` must parse as, pinned by `tests/vectors/stored-at-stamp` (RFC 3339 `YYYY-MM-DDThh:mm:ss`, a 1 to 9 digit fraction, `Z` or `+hh:mm`/`-hh:mm`; uppercase `T`/`Z`, a real date, seconds 00-59, years 0001-9999), hand-checked because `chrono`'s RFC 3339 reader takes other shapes. `put_raw_envelope` and `put_prepared_batch` refuse a `stored_at` that is not a stamp (before: only a blank one) and mint nothing (variants:ruling:raw-put-without-stamp-refused).
- The single-file store's `push`, `push_all` and `commit_if` stamp a replacing write strictly later than the record it replaces at the same key (a staged stamp stands only when it parses and is strictly later; otherwise the stamp is minted as the replaced stamp plus one microsecond, or now if later), with no identical-content exception, as C# does. A stamp with no successor (the replaced stamp is at year 9999's last microsecond) refuses the write and writes nothing, as C# `MintStoredAt` does. `commit_if` replaces only the record of the entry's own type under the key, so a retype is refused as `push` refuses it. The cache holds the stamp the store wrote. The redb stores and the store-level exact-envelope writes (`compare_and_swap_entry` and its family) still write the caller's envelope unchanged.
- The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock.
- `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store.
### Breaking
Semver: this is a breaking change in the 0.x line (0.3.x to 0.4.0).
- `CultCache::update` is replaced, not extended: `update::<T, _>(key, apply)` takes `FnMut(Option<T>) -> Option<T>` and returns `Result<CultUpdateResult<T>>`. The blind `FnOnce(Option<T>) -> T` form that returned `T` is gone; use `put` for a blind write. It reads the home store, not the cache's image, so a cache whose store has no `read` and `commit_if` (the redb stores, a custom store) refuses it before `apply` runs.
- `CacheBackingStore::push` returns `Result<String>`, the `stored_at` written, and `push_all` returns `Result<Vec<String>>`, one stamp per entry given in order (a soft write that wrote nothing returns an empty vec). A custom store must return what it wrote; the redb stores return the entries' own stamps. Huginn implements `CacheBackingStore` in five test stores under a pinned git rev: they change at its next bump.
- A write at a store path that is a symbolic link, live or dangling, is refused with the new public `StoreLinkedError` (`error.downcast_ref::<StoreLinkedError>()`) before the lock and the replace, and writes nothing: `push`, `push_all`, `delete`, the exchanges, and a soft `push_all`, which used to skip a link and now refuses. Before, a write at a live link replaced the link with a regular file and a dangling link failed with `io::ErrorKind::NotFound`. The redb stores (`RedbMessagePackBackingStore`, `OwnedRedbMessagePackBackingStore`) are exempt: they are ruled migrated and deleted (`paged-store-redb-migrate-and-delete`) and still open a live link's target. Configure the link's target as the store path. Reads through a live link work.
- A record whose payload carries unread slots is read-only. A payload with a slot past its type's declared slots, at any depth (record, nested record, list element, union arm, unit arm), still decodes, and every write or removal of its key is now refused before any store is touched: `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` (the whole batch) and `delete`, so `update` too. The refusal is `SchemaConflictError` with the new `unread_slots`; its message names the type and both slot counts and never a slot value. Holders of such a record used to overwrite it and drop the extra slots silently. The payload nesting refusal still runs first. A held payload this build cannot read at all (an arm it lacks from a newer writer, or a corrupt record) is read-only too: every write or removal is refused with an error naming the type and key (a newer writer's record is read with that build; a corrupt one is restored from a backup or removed at the store itself, as no cache call removes it), and `is_writable` answers false. `load_soa` is a write of every held key it omits or changes, so it refuses to drop or replace a read-only record. `is_writable` also answers false for a key a store carries as a foreign record and for a type other than the one holding the key, as the writes do. Each write over a held key decodes the held payload once to ask (about 40% of a `put` of a 200,000-element record, measured in release); keys nothing holds pay nothing.
- cultmesh-rs `pull_rudp_catalog_snapshot` applies a snapshot as one cache commit (`put_prepared_batch`), as C# does: a snapshot document for a key the node holds read-only, or any other refusal, applies nothing. It used to apply document by document, so a refusal ended the pull with the earlier documents kept. A snapshot whose types route to different backing stores is now refused whole, as a batch cannot span stores. C# `CultCache.Commit` refuses a cross-store commit the same way (`The batch spans A and B; a commit lands in one home store`), so the two agree; a test pins it.
- `SchemaConflictError` gains the public field `unread_slots: Option<UnreadSlots>`; code that builds it with a struct literal must set it (`None`).
- A unit arm with slots (`[N, [extra...]]`) decodes as the unit arm and marks the record, where the derive refused it.
- The registration map's value changes from the schema name to a private struct holding the schema name and the unread-slots check.
- A cache whose last `pull_all_backing_stores` was refused (a tail, a payload past the nesting bound, a duplicate unique-index value, a retype conflict, a record from the wrong store) refuses every `put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch`, `update` and `delete` with `CultCacheLoadRefused` until a pull succeeds. Before, it stayed writable over an empty image, and the next batch write replaced the store with only the new records.
- `CacheBackingStore::push_all` writes the given entries onto the store as it is when the write lands, under the store's lock or transaction: a record under the same identity is replaced and no other record is removed. It used to replace everything the store held, so a cache whose image was stale (another writer had added a record since its pull) wiped that record with `put_prepared_batch`. `put_prepared_batch` now hands the store its staged records alone, as C# `CommitBatch` and Python `push_all` do; the lay-back of foreign records is gone because they are never rewritten. A custom `CacheBackingStore` must give `push_all` merge semantics. `push_all` reads the store, so through a dangling link it is refused like `push` and no longer replaces the link; a soft flush is unchanged.
- A cache with a backing store also refuses every write and removal, with `CultCacheLoadRefused`, until it has pulled: a cache that never pulled, or that gained a store since its last pull, holds no image of its stores, and a batch from it replaced a populated store with only the new records. `is_writable` answers false meanwhile. A cache with no store needs no pull. Callers that attach a store and write (cultmesh nodes created with `pull_on_start: false`) must call `pull_all_backing_stores` first.
- `decode_payload`, `put_raw_envelope` and prepared batches refuse a payload with bytes after its first MessagePack value (`payload has {n} bytes after its first MessagePack value`), by count; the tail is never walked or quoted. They used to read the first value and keep or drop the tail, so a record with a stray tail was served and rewritten without it. A store holding such a record (single-file, redb, `load_soa`) is refused at load, naming the type, the key and the count, and stays unreadable until the record is repaired; the same check now runs on every record at load, as the C# and Python runtimes do at open. The nesting bound is part of that check, so a stored payload nested past it is refused at load too, where it used to load and refuse on read.
- A single-file store written as `cultcache.store.v3` is read and written: a store the C# runtime marked v3 (it holds a record with an element id: an owned list element with a `[CultElementId]` member) keeps its header here, and is carried through every rewrite with its ids; this runtime never drops the mark. A runtime from before this change refuses a v3 store by name. Rolling back to an older release after a store has been written as v3 is one-way: there is no carry-back, so restore from a copy taken before upgrading.
- A write over a file the reader refuses (a zero-byte file, a truncated store, a stamp this runtime does not read) refuses with `CultCacheStoreUnreadable` and leaves the file as it is. A whole-store write used to replace such a file. This reverses 0.3.1, where `push_all` replaced a file with no readable stamp (healing it); the stamp rule now has one owner, the reader every write asks under the lock, and it accepts v1 and v3 (0.3.1 refused v3).
- `#[cultcache(index)]` means unique, as in C# `RefuseIndexSharing`: within one type and one alias no two records hold one non-blank value. A put, batch, push or load that would make two records hold a value is refused, naming both keys and the alias, and a store that already holds a duplicate does not open. A grouping index is no longer expressible; a consumer that relied on shared values must drop the `index` and look up by another way.
- `bool` index and lookup text renders `True` / `False`, as C# does, where it rendered `true` / `false`.
- `DatabaseEntry::UNIQUE_INDEXES` replaces `cult_indexes` as the one index declaration and changes type (its members are the unique aliases); a hand implementation overriding `cult_indexes` no longer compiles.
- `register_entry_type` over a type that already holds records re-judges them under the new declaration (unique indexes and the global rule) and refuses, changing nothing, when they violate it. A declaration with an index also refuses when a held record cannot be decoded as the new type; a declaration without one does not read the records, so it does not.
- A single-file store holds one record per key. The v1 and legacy readers refuse a store with two records under one key (`CultCacheStoreUnreadable`, the record `Undecodable`): the store does not open, and a write over it refuses and leaves the file. A consumer that holds such a file must repair it with the runtime that wrote it before opening it here.
- Every single-file writer (`push`, `push_all`, `insert_entry_if_absent`, the compare-exchange and snapshot-append family, and the cache's puts and batches) refuses to put a second type under a key the store holds, with `SchemaConflictError` naming the key and both types, writing nothing. Retyping a key is a delete, then an add (one batch may hold both). A write owns the held record when its schema id equals the id the held record is stored under, as C# `WrittenOwns` asks; a type renamed under a stable id therefore replaces its own record, and a write of another type name under another id is refused (`push` of the same type name under another id still replaces the record, as before; `compare_and_swap_entry`, and a `push_all` batch holding one type under two ids, refuse it). Registered types here declare no compatible ids, so equality is the whole test. Writers used to leave two records under the key.
- `compare_and_swap_entry` over a single file refuses an in-place retype (expected `A/k`, replacement `B/k` under another schema id) with the same `SchemaConflictError`, as `compare_exchange` does, where it used to retype the record and return `true`. A lost race still returns `false` first.
- A cache holds one record per key. `put`, `put_envelope`, `put_raw_envelope` and a batch of a second type under a key the cache holds, or a key a store carries foreign, are refused with `SchemaConflictError` even with no store (a memory-only cache used to hold both), whatever schema id the envelope claims: another registered type's record is never owned. `put_envelope`, `put_raw_envelope`, `put_prepared_batch` and `load_soa` also refuse, at any key, an envelope whose schema id names another type registered in this cache (the id a pull would resolve the record by). An id this cache does not register is trusted as the envelope claims it: at a key this cache does not hold, the single-file store decides ownership by that id, so such an envelope can replace another writer's record stored under it, and a reader registering the type the id names serves the record as that type. Store-level writers called directly (`push`, `insert_entry_if_absent`, compare-and-swap) still trust the claimed id. A refused write or pull changes nothing. Two stores holding one key refuse the pull, naming the key and both types, and so does one store holding two records at a key that resolve to one type. A store holding a record no registered type claims now loads (it used to refuse with "No schema is registered for persisted entry type") and the record is carried, through `put_prepared_batch` too, which lays foreign records back byte for byte; `delete` at a foreign key is refused. `load_soa` refuses a second type at a key or a record at a foreign key, and still refuses an unregistered type. The redb stores still key by `(type, key)`, and their own writers (`push`, `insert_entry_if_absent`, compare-and-swap) still put a second type at a held key: a cache now refuses to pull such a redb store with `SchemaConflictError`, where it used to load both records.
- A write of the same type under a held key replaces the record by key, and in one `push_all` the last given of several entries under a key and a schema id is the record, whatever their type names. The redb stores are unchanged and still key by type and key.
### Added
- `UnreadSlots { type_name, slot_count, declared_count, occurrences }`, `decode_payload_marked` (the decode that returns the mark) and `CultCache::is_writable(type, key)`. The derives' slot visitors note into a thread-local scope the decode entry opens (`__enter_slots`, `__note_unread_slots`, `__UnitArmSlots`, doc-hidden); a decode outside that entry notes nothing.
- `CultCache::foreign_records()` and `ForeignRecord { key, schema_id, schema_name, stored_at }`: the records the last pull found that no registered type claims, sorted by key.
- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name find the one with the smallest key (an index value is unique, below). A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`).
- `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::<SchemaConflictError>()`.
## [0.3.1]
### Fixed
- `push_all` reads the store's header under the lock and refuses a format this runtime does
not read, with `CultCacheStoreUnreadable` `UnsupportedFormat`, leaving the file as it was.
A file with no readable stamp is still replaced, so a whole write heals it.

View file

@ -1,802 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "ar_archive_writer"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec"
dependencies = [
"object",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cultcache-rs"
version = "0.3.1"
dependencies = [
"anyhow",
"chrono",
"cultcache-rs-derive",
"fs2",
"pretty_assertions",
"redb",
"rmp-serde",
"serde",
"serde_bytes",
"serde_json",
"serde_stacker",
"sha2",
"stacker",
"tempfile",
"uuid",
"windows-sys 0.59.0",
]
[[package]]
name = "cultcache-rs-derive"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "diff"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "errno"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "itoa"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"
[[package]]
name = "js-sys"
version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pretty_assertions"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
"diff",
"yansi",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "redb"
version = "4.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b317ef681e18f6091efbfab532eaf924a135c492d9d0299d0fbfae07e92a39b"
dependencies = [
"libc",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rustix"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_bytes"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.10",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_stacker"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4936375d50c4be7eff22293a9344f8e46f323ed2b3c243e52f89138d9bb0f4a"
dependencies = [
"serde",
"serde_core",
"stacker",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys 0.61.2",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "uuid"
version = "1.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c"
dependencies = [
"getrandom",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-bindgen"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.10",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "yansi"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

View file

@ -1,6 +1,6 @@
[package]
name = "cultcache-rs"
version = "0.3.1"
version = "0.3.0"
edition = "2024"
license = "MIT"
publish = false
@ -23,7 +23,6 @@ serde = { version = "1", features = ["derive"] }
serde_bytes = "0.11"
serde_json = "1"
serde_stacker = "0.1.14"
sha2 = "0.10"
stacker = "0.1.25"
uuid = { version = "1", features = ["v4"] }
@ -32,4 +31,4 @@ pretty_assertions = "1.4"
tempfile = "3"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_WindowsProgramming"] }
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }

View file

@ -21,14 +21,8 @@ CultCache is a domain cache with persistence adapters.
- `CultCache` is the query and mutation surface.
- Domain structs implement `DatabaseEntry`.
- A cache holds one record per key whatever its type. Records are held per
`(type, key)`, but a write of a second type under a key the cache holds, or a
key a store carries foreign, is refused with `SchemaConflictError` naming the
key (no store needed); retyping a key is a delete, then an add. A store
record no registered type claims loads: the cache does not serve it, lists it
in `foreign_records()`, lays it back byte for byte through every write
including `put_prepared_batch`, and refuses a write or delete at its key.
Registering a type that claims its schema id picks it up at the next pull.
- Entries are stored behind a `type::key` identity, so multiple entry types
can share a logical key without colliding.
- Each payload is encoded directly from the known `DatabaseEntry` type into
MessagePack bytes.
- Backing stores are adapters, not the public data model.
@ -39,8 +33,8 @@ CultCache is a domain cache with persistence adapters.
file access with a sidecar lock file.
This is not an ORM, not a database, and not distributed consensus in a novelty
hat. Several processes may write the same single-file store, one at a time under
its sidecar lock; write ordering across keys or stores needs a coordinator.
hat. If multiple processes write the same backing file, use an external lock or
a coordinator.
## Current API
@ -180,13 +174,10 @@ without pretending Rust has runtime reflection hiding under the floorboards.
- `get_required_envelope::<T>`
- `get_all::<T>`
- `get_all_with_keys::<T>`
- `get_by_name::<T>`
- `get_by_index::<T>`
- `get_global::<T>`
- `soa::<T>`
- `put::<T>`
- `put_envelope::<T>`
- `update::<T>` and `update_with_attempts::<T>`: the conditional golden-path write (read the stored record, apply, commit only over what was read, rerun on a lost race)
- `update::<T>`
- `delete::<T>`
- `snapshot`
- `snapshot_soa`
@ -236,17 +227,6 @@ assert_eq!(rmp_serde::from_slice::<Node>(&bytes)?, tree);
# Ok::<(), Box<dyn std::error::Error>>(())
```
## Records written by a newer build
A payload that carries a slot past the slots its type declares, in a record, a nested record, a list element or a union
arm (a unit arm declares none), still decodes: the extra slots are skipped. The cache then holds that record read-only,
because writing it would drop what the newer writer stored. `put`, `put_envelope`, `put_raw_envelope`,
`put_prepared_batch` and `delete` of its key are refused with `SchemaConflictError`, whose `unread_slots` names the type
and the slot counts and never a value; `CultCache::is_writable(type, key)` answers the same check. An untouched
read-only record keeps its bytes through every other write. A short payload (missing trailing slots) is not marked, and
a payload nested past `MAX_PAYLOAD_DEPTH` is refused before anything is read. `decode_payload_marked` is the decode that
returns the `UnreadSlots` mark. This runtime has no compatible schema ids, so no mark is consented.
## Structure Of Arrays
The C# CultCache hot path exposes CPU-local SoA scans as:
@ -304,14 +284,6 @@ The contract, shared with every runtime, is one home store per document type
## Persistence Semantics
`update` is the golden path for changing a record another writer may also change: each attempt reads the record and its content
version from the home store, hands `apply` a copy, and commits only if the store still holds that version. `put` is the blind
write. The single-file store stamps a replacing write (`push`, `push_all` and `update`'s commit) strictly later than the record
it replaces, and the cache holds the stamp the store reports; the redb stores and the exact-envelope family
(`compare_and_swap_entry` and the other store-level conditional writes) write the caller's envelope byte for byte. A store
needs `read` and `commit_if` to serve `update`; the redb stores do not have them.
The version is the SHA-256 of the record re-encoded as the store file writes it; that equals the hash of the record's bytes in the file (and the C# reference's vector) whenever the file's MessagePack encodings are minimal, as every runtime's writer produces.
`put` persists before mutating the in-memory cache. If persistence fails, the
cache does not pretend the write succeeded.
@ -392,10 +364,10 @@ an empty store. Any other failure to reach it, such as a symlink loop, a file
where a parent directory should be, or a missing permission, is an I/O error.
A store file that exists and holds zero bytes is `Undecodable`: no CultCache
writer leaves one. A symbolic link whose target is gone is not a missing store:
reading it is an I/O error. A write at a store path that is a link, live or dangling, is
refused with `StoreLinkedError` (`error.downcast_ref::<StoreLinkedError>()`) before the
lock and the replace, so no write swaps a link for a regular file; configure the link's
target as the store path. Reads through a live link work. A refusal names no
reading it is an I/O error, so a read-then-write (`push`, `delete`, the exchanges)
fails before writing. Writes do not yet follow links: a write through a live link,
and a `push_all` through a dangling one, replace the link with a regular file and
leave its target alone. A refusal names no
value read from the file; it names a record only by its key and schema id, and
a header only when it has the shape `cultcache.store.v<digits>`.
@ -419,10 +391,6 @@ fn outcome(error: &anyhow::Error) {
}
```
## Store format and rollback
A store whose elements hold an id is marked `cultcache.store.v3` by the C# runtime. This runtime reads and writes a v3 single-file store, keeps the header it read and carries the ids through a rewrite; it never drops the mark, so it has no carry-back. A cultcache-rs from before element ids refuses a v3 store by name, so rolling back to it after a store has been written as v3 is one-way: restore from a copy taken before upgrading, and upgrade every reader before any writer. (The C# line is different: CultLib 1.x does not refuse; see the GameCult.Caching README.)
## Near-Term Ergonomic Improvements
1. **Derive macro**
@ -441,9 +409,7 @@ A store whose elements hold an id is marked `cultcache.store.v3` by the C# runti
3. **Name and index lookups**
- `cache.get_by_name::<T>("Potion")`
- `cache.get_by_index::<T>("callsign", "Lucent-7")`: `#[cultcache(index)]` is unique. Within one type no two
records hold one non-blank value of the alias; a put, batch or load that would make two is refused, naming both
keys and the alias, and writes nothing. A grouping (faction, kind, status) is not an index: filter `get_all`.
- `cache.get_by_index::<T>("faction", "Lucent")`
- generated extractors instead of reflection strings
4. **Schema/version metadata**
@ -483,7 +449,7 @@ That is the Rust version of the original trick:
- C# refuses to store objects outside the `DatabaseEntry` root type.
- Runtime-visible `DatabaseEntry` subclasses get dedicated formatters.
- A write of an unregistered type is rejected; a stored record of an unregistered type is carried as foreign, not decoded.
- Unknown or unregistered types are rejected.
- MessagePack payload bytes deserialize through known concrete formatters, not
an open-world type loader or generic structured-value bridge.

View file

@ -22,7 +22,6 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
let ident = input.ident.clone();
let mut entry_type: Option<LitStr> = None;
let mut schema_name: Option<LitStr> = None;
let mut global = false;
for attribute in input
.attrs
@ -38,12 +37,6 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
once(&schema_name, &meta, "schema")?;
schema_name = Some(meta.value()?.parse()?);
Ok(())
} else if meta.path.is_ident("global") {
if global {
return Err(meta.error("repeated `global`: #[cultcache(global)] is given once"));
}
global = true;
Ok(())
} else {
Err(meta.error("unsupported cultcache attribute"))
}
@ -61,33 +54,11 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStr
let slots = named_slots(input.data, &ident, "DatabaseEntry")?;
let codec = slot_codec(&ident, &slots);
let name_member = slots.iter().find(|slot| slot.name).map(|slot| &slot.ident);
let name_fn = name_member.map(|member| {
quote! {
fn cult_name(&self) -> ::std::option::Option<::std::string::String> {
::cultcache_rs::CultLookupValue::lookup_value(&self.#member)
}
}
});
let index_members: Vec<_> = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))).collect();
let index_rows = index_members.iter().map(|(alias, member)| {
quote! {
(#alias, (|record: &Self| ::cultcache_rs::CultLookupValue::lookup_value(&record.#member))
as fn(&Self) -> ::std::option::Option<::std::string::String>)
}
});
let index_rows: Vec<_> = index_rows.collect();
let unique_indexes = (!index_rows.is_empty()).then(|| {
quote! { const UNIQUE_INDEXES: &'static [::cultcache_rs::CultIndexMember<Self>] = &[#(#index_rows),*]; }
});
Ok(quote! {
impl ::cultcache_rs::DatabaseEntry for #ident {
const TYPE: &'static str = #entry_type;
const SCHEMA_NAME: &'static str = #schema_name;
const GLOBAL: bool = #global;
#unique_indexes
#name_fn
}
#codec
@ -162,8 +133,6 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
let mut key: Option<usize> = None;
let mut default = false;
let mut bytes = false;
let mut name = false;
let mut index: Option<String> = None;
for attribute in field
.attrs
.iter()
@ -188,22 +157,6 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
} else if meta.path.is_ident("bytes") {
bytes = true;
Ok(())
} else if owner == "DatabaseEntry" && meta.path.is_ident("name") {
if name {
return Err(meta.error("repeated `name`: #[cultcache(name)] is given once"));
}
name = true;
Ok(())
} else if owner == "DatabaseEntry" && meta.path.is_ident("index") {
once(&index, &meta, "index")?;
// A bare `index`, or an alias that is blank, names the field itself.
let alias = if meta.input.peek(syn::Token![=]) {
meta.value()?.parse::<LitStr>()?.value()
} else {
String::new()
};
index = Some(if alias.trim().is_empty() { field_ident.to_string() } else { alias });
Ok(())
} else {
Err(meta.error("unsupported field cultcache attribute"))
}
@ -227,28 +180,8 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result<Vec<C
ty: field.ty,
default,
bytes,
name,
index,
});
}
if let Some(second) = slots.iter().filter(|slot| slot.name).nth(1) {
return Err(syn::Error::new_spanned(
&second.ident,
format!("{owner} declares #[cultcache(name)] on more than one member; a record has one name"),
));
}
for (position, slot) in slots.iter().enumerate() {
let Some(alias) = &slot.index else { continue };
if let Some(first) = slots[..position].iter().find(|earlier| earlier.index.as_ref() == Some(alias)) {
return Err(syn::Error::new_spanned(
&slot.ident,
format!(
"{owner} members {} and {} both declare index alias \"{alias}\"; an index alias must name one member",
first.ident, slot.ident
),
));
}
}
slots.sort_by_key(|slot| slot.key);
Ok(slots)
}
@ -381,7 +314,6 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token
where
__A: ::serde::de::SeqAccess<'de>,
{
let __ticket = ::cultcache_rs::__enter_slots();
#(#option_declarations)*
let mut index = 0usize;
while index <= #max_slot {
@ -391,15 +323,7 @@ fn slot_codec(ident: &syn::Ident, slots: &[CultCacheSlot]) -> proc_macro2::Token
}
index += 1;
}
// The slots past the declared ones are skipped and counted: the decode entry marks the record
// that carried them read-only.
let mut __extra = 0usize;
while seq.next_element::<::serde::de::IgnoredAny>()?.is_some() {
__extra += 1;
}
if __extra > 0 {
::cultcache_rs::__note_unread_slots(__ticket, stringify!(#ident), #max_slot + 1 + __extra, #max_slot + 1);
}
while let ::std::option::Option::Some(_) = seq.next_element::<::serde::de::IgnoredAny>()? {}
Ok(#ident {
#(#build_fields),*
})
@ -443,11 +367,6 @@ struct CultCacheSlot {
ty: Type,
default: bool,
bytes: bool,
/// `#[cultcache(name)]`: the member `get_by_name` finds the record by.
name: bool,
/// `#[cultcache(index)]` or `#[cultcache(index = "alias")]`: a unique lookup. Within one type no two records hold one
/// non-blank value of the alias; `get_by_index` finds the record by it.
index: Option<String>,
}
fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
@ -576,13 +495,10 @@ fn union_codec(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
quote! {
#key => {
match seq
.next_element::<::std::option::Option<::cultcache_rs::__UnitArmSlots>>()
.next_element::<::std::option::Option<[(); 0]>>()
.map_err(|_| ::serde::de::Error::custom(#arity_message))?
{
::std::option::Option::Some(::std::option::Option::Some(slots)) => {
slots.note(stringify!(#variant));
#ident::#variant
}
::std::option::Option::Some(::std::option::Option::Some(_)) => #ident::#variant,
::std::option::Option::Some(::std::option::Option::None) => {
return ::std::result::Result::Err(::serde::de::Error::custom(
format!(#nil_slots_message, #key),
@ -926,89 +842,4 @@ mod tests {
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted");
}
fn entry_error(source: &str) -> String {
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
database_entry_codec(input).expect_err("the fixture is refused").to_string()
}
fn entry_tokens(source: &str) -> String {
let input: DeriveInput = syn::parse_str(source).expect("the fixture parses");
database_entry_codec(input).expect("the fixture is accepted").to_string().replace(' ', "")
}
#[test]
fn name_index_and_global_generate_the_lookup_surface() {
let tokens = entry_tokens(
r#"#[cultcache(type = "t", global)] struct S {
#[cultcache(key = 0, name)] label: String,
#[cultcache(key = 1, index)] code: String,
#[cultcache(key = 2, index = "sku")] stock: Option<String>,
}"#,
);
assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}");
assert!(tokens.contains("fncult_name"), "{tokens}");
assert!(tokens.contains("(\"code\","), "{tokens}");
assert!(tokens.contains("(\"sku\","), "{tokens}");
}
#[test]
fn a_record_without_lookups_generates_none_of_them() {
let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#);
assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}");
assert!(!tokens.contains("fncult_name") && !tokens.contains("UNIQUE_INDEXES"), "{tokens}");
}
// ResolveIndexAlias: a blank alias is the member's name.
#[test]
fn a_blank_index_alias_is_the_member_name() {
let tokens = entry_tokens(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#,
);
assert!(tokens.contains("(\"code\","), "{tokens}");
}
#[test]
fn two_names_are_refused_for_that_reason() {
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name)] a: String, #[cultcache(key = 1, name)] b: String }"#,
);
assert!(message.contains("more than one member"), "{message}");
}
// D10: two members that declare one alias; a bare index is the member name, so it collides with an explicit alias of it.
#[test]
fn two_members_with_one_index_alias_are_refused_for_that_reason() {
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = "k")] a: String, #[cultcache(key = 1, index = "k")] b: String }"#,
);
assert!(message.contains("both declare index alias \"k\""), "{message}");
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index)] a: String, #[cultcache(key = 1, index = "a")] b: String }"#,
);
assert!(message.contains("both declare index alias \"a\""), "{message}");
}
#[test]
fn a_repeated_lookup_attribute_is_refused() {
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name, name)] a: String }"#,
);
assert!(message.contains("repeated `name`"), "{message}");
let message = entry_error(
r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index, index = "x")] a: String }"#,
);
assert!(message.contains("repeated `index`"), "{message}");
let message = entry_error(r#"#[cultcache(type = "t", global, global)] struct S { #[cultcache(key = 0)] a: String }"#);
assert!(message.contains("repeated `global`"), "{message}");
}
// Only a DatabaseEntry is looked up: a nested slot record carrying the attributes is refused as before.
#[test]
fn lookup_attributes_are_refused_on_a_slots_record() {
let message = slots_error(r#"struct S { #[cultcache(key = 0, name)] a: String }"#);
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
let message = slots_error(r#"struct S { #[cultcache(key = 0, index)] a: String }"#);
assert!(message.contains("unsupported field cultcache attribute"), "{message}");
}
}

View file

@ -1,8 +1,6 @@
use anyhow::Result;
use anyhow::anyhow;
use cultcache_rs::CultCache;
use cultcache_rs::CultSlots;
use cultcache_rs::CultUnion;
use cultcache_rs::DatabaseEntry;
use cultcache_rs::SingleFileMessagePackBackingStore;
use serde_json::json;
@ -28,132 +26,6 @@ struct CultCacheInteropNote {
tags: Vec<String>,
}
// An object list: each mark is a positional array [label, id], the id an ordinary member at slot 1.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct CultCacheInteropMark(String, String);
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "cultcache.interop-deck", schema = "CultCacheInteropDeck")]
struct CultCacheInteropDeck {
#[cultcache(key = 0)]
document_id: String,
#[cultcache(key = 1, default)]
marks: Vec<CultCacheInteropMark>,
}
// A union field and nested bytes: the shapes a Bifrost-style store holds beyond plain strings.
#[derive(Clone, Debug, PartialEq, Eq, CultUnion)]
enum InteropArm {
#[cultcache(key = 0)]
Text(InteropTextArm),
#[cultcache(key = 1)]
Pack(InteropPackArm),
}
#[derive(Clone, Debug, PartialEq, Eq, CultSlots)]
struct InteropTextArm {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, CultSlots)]
struct InteropPackArm {
#[cultcache(key = 0)]
label: String,
#[cultcache(key = 1)]
chunks: Vec<serde_bytes::ByteBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "cultcache.interop-shape", schema = "CultCacheInteropShape")]
struct CultCacheInteropShape {
#[cultcache(key = 0)]
document_id: String,
#[cultcache(key = 1)]
arm: InteropArm,
#[cultcache(key = 2)]
raw: serde_bytes::ByteBuf,
}
fn shapes() -> Vec<CultCacheInteropShape> {
vec![
CultCacheInteropShape {
document_id: "shape:text".into(),
arm: InteropArm::Text(InteropTextArm { text: "union arm 0".into() }),
raw: (0..=255u8).collect::<Vec<_>>().into(),
},
CultCacheInteropShape {
document_id: "shape:pack".into(),
arm: InteropArm::Pack(InteropPackArm {
label: "union arm 1".into(),
chunks: vec![vec![0xc0, 0xc1, 0x00].into(), vec![].into(), vec![0x92, 0x01, 0x02].into()],
}),
raw: vec![0xde, 0xad, 0xbe, 0xef].into(),
},
]
}
fn write_shapes(file: &str) -> Result<()> {
let mut cache = CultCache::new();
cache.register_entry_type::<CultCacheInteropShape>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
cache.pull_all_backing_stores()?;
for shape in shapes() {
cache.put(&shape.document_id, &shape)?;
}
print_shapes(shapes())
}
/// Opens the store through the read-only snapshot door: no lock file, no staging file, no rewrite.
fn read_shapes(file: &str) -> Result<()> {
let envelopes = SingleFileMessagePackBackingStore::new(file).pull_all_read_only_snapshot()?;
let mut found = Vec::new();
for envelope in envelopes.iter().filter(|envelope| envelope.r#type == CultCacheInteropShape::TYPE) {
found.push(cultcache_rs::decode_payload::<CultCacheInteropShape>(&envelope.payload)?);
}
print_shapes(found)
}
fn print_shapes(mut found: Vec<CultCacheInteropShape>) -> Result<()> {
let hex = |bytes: &[u8]| bytes.iter().map(|byte| format!("{byte:02X}")).collect::<String>();
found.sort_by(|a, b| a.document_id.cmp(&b.document_id));
let rows: Vec<_> = found
.iter()
.map(|shape| {
let arm = match &shape.arm {
InteropArm::Text(text) => json!({ "kind": "text", "text": text.text }),
InteropArm::Pack(pack) => json!({
"kind": "pack",
"label": pack.label,
"chunks": pack.chunks.iter().map(|chunk| hex(chunk)).collect::<Vec<_>>(),
}),
};
json!({ "documentId": shape.document_id, "arm": arm, "raw": hex(&shape.raw) })
})
.collect();
println!("{}", serde_json::to_string(&rows)?);
Ok(())
}
fn read_deck(file: &str) -> Result<()> {
let mut cache = CultCache::new();
cache.register_entry_type::<CultCacheInteropDeck>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(file))?;
cache.pull_all_backing_stores()?;
let decks = cache.get_all::<CultCacheInteropDeck>()?;
let deck = decks
.first()
.ok_or_else(|| anyhow!("no cultcache.interop-deck records found"))?;
println!(
"{}",
serde_json::to_string(&json!({
"documentId": deck.document_id,
"ids": deck.marks.iter().map(|mark| mark.1.clone()).collect::<Vec<_>>(),
}))?
);
Ok(())
}
fn main() -> Result<()> {
let mut args = std::env::args().skip(1);
let mode = args
@ -165,9 +37,6 @@ fn main() -> Result<()> {
match mode.as_str() {
"write" => write_note(file, require_arg(&options, "runtime-id")?)?,
"read" => read_note(file)?,
"read-deck" => read_deck(file)?,
"write-shapes" => write_shapes(file)?,
"read-shapes" => read_shapes(file)?,
"hold-lock" => hold_lock(file, require_arg(&options, "ms")?.parse()?)?,
_ => return Err(anyhow!("unknown mode {mode}")),
}

File diff suppressed because it is too large Load diff

View file

@ -1,235 +0,0 @@
//! The union-parity vectors' types and constructed value, shared by the test files that walk them.
#![allow(dead_code)]
use std::path::PathBuf;
use cultcache_rs::CultSlots;
use cultcache_rs::CultUnion;
use cultcache_rs::DatabaseEntry;
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum Node {
#[cultcache(key = 0)]
Box(BoxArm),
#[cultcache(key = 1)]
Text(TextArm),
#[cultcache(key = 2)]
Use(UseArm),
#[cultcache(key = 5)]
Image(ImageArm),
#[cultcache(key = 6)]
Wrap(WrapArm),
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct WrapArm {
#[cultcache(key = 0)]
pub inner: Option<std::boxed::Box<Node>>,
#[cultcache(key = 1)]
pub id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct BoxArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub children: Vec<Node>,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct TextArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub content: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct UseArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub composition: Composition,
#[cultcache(key = 2)]
pub slots: Vec<Option<Node>>,
}
// Slot 2 is retired: a gap written nil.
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct ImageArm {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub uri: String,
#[cultcache(key = 3)]
pub alt: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct Composition {
#[cultcache(key = 0)]
pub name: String,
#[cultcache(key = 1)]
pub body: Option<std::boxed::Box<Node>>,
}
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum Outcome {
#[cultcache(key = 0)]
Pending(PendingArm),
#[cultcache(key = 1)]
Completed(CompletedArm),
#[cultcache(key = 2)]
Denied(IdArm),
#[cultcache(key = -2147483648)]
Floor(IdArm),
#[cultcache(key = 2147483647)]
Ceiling(IdArm),
}
// The codec's unit arm (no members, written [key, []]), kept apart from the golden: a document list's arms carry ids.
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum UnitOutcome {
#[cultcache(key = 2)]
Denied,
#[cultcache(key = -2147483648)]
Floor,
#[cultcache(key = 2147483647)]
Ceiling,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct PendingArm {
#[cultcache(key = 0)]
pub message: String,
#[cultcache(key = 1)]
pub id: String,
}
// An arm with no members of its own: just its element id.
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct IdArm {
#[cultcache(key = 0)]
pub id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct CompletedArm {
#[cultcache(key = 0)]
pub source_version: i32,
#[cultcache(key = 1)]
pub id: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct NamedArm {
#[cultcache(key = 0)]
pub name: String,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct UsesArm {
#[cultcache(key = 0)]
pub name: String,
#[cultcache(key = 1)]
pub uses: i32,
}
#[derive(Clone, Debug, PartialEq, CultSlots)]
pub struct CountArm {
#[cultcache(key = 0)]
pub name: String,
#[cultcache(key = 1)]
pub count: i32,
}
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum Item {
#[cultcache(key = 0)]
Gem(NamedArm),
#[cultcache(key = 1)]
Tool(UsesArm),
#[cultcache(key = 2)]
Part(CountArm),
}
// The same arms as Item under its own keys: key 2 is unknown to it.
#[derive(Clone, Debug, PartialEq, CultUnion)]
pub enum CraftedItem {
#[cultcache(key = 0)]
Tool(UsesArm),
#[cultcache(key = 1)]
Part(CountArm),
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "tests.union_surface", schema = "tests.union_surface.v1")]
pub struct UnionSurface {
#[cultcache(key = 0)]
pub id: String,
#[cultcache(key = 1)]
pub version: i32,
#[cultcache(key = 2)]
pub root: Option<Node>,
#[cultcache(key = 3)]
pub outcomes: Vec<Outcome>,
#[cultcache(key = 4)]
pub part: Option<Item>,
#[cultcache(key = 5)]
pub crafted: Option<CraftedItem>,
#[cultcache(key = 6)]
pub missing: Option<Node>,
}
pub fn text(id: &str, content: &str) -> Node {
Node::Text(TextArm { id: id.into(), content: content.into() })
}
pub fn image(id: &str, uri: &str, alt: &str) -> Node {
Node::Image(ImageArm { id: id.into(), uri: uri.into(), alt: alt.into() })
}
pub fn boxed(id: &str, children: Vec<Node>) -> Node {
Node::Box(BoxArm { id: id.into(), children })
}
pub fn constructed() -> UnionSurface {
let inner = boxed("0000000000d0", vec![text("0000000000d1", "x"), image("0000000000d2", "uri://a", "alt")]);
UnionSurface {
id: "surface".into(),
version: 3,
root: Some(boxed(
"0000000000c0",
vec![
text("0000000000c1", "hello"),
Node::Use(UseArm {
id: "0000000000c2".into(),
composition: Composition { name: "comp".into(), body: Some(std::boxed::Box::new(inner)) },
slots: vec![Some(text("0000000000e1", "slot")), None, Some(boxed("0000000000e2", vec![]))],
}),
image("0000000000c3", "uri://b", ""),
],
)),
outcomes: vec![
Outcome::Pending(PendingArm { message: "wait".into(), id: "0000000000a1".into() }),
Outcome::Completed(CompletedArm { source_version: 7, id: "0000000000a2".into() }),
Outcome::Denied(id_arm("0000000000a3")),
],
part: Some(Item::Tool(UsesArm { name: "hammer".into(), uses: 12 })),
crafted: Some(CraftedItem::Part(CountArm { name: "gear".into(), count: 4 })),
missing: None,
}
}
pub fn id_arm(id: &str) -> IdArm {
IdArm { id: id.into() }
}
pub fn vector(name: &str) -> Vec<u8> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/vectors/union-parity")
.join(name);
std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()))
}

View file

@ -1,26 +0,0 @@
//! `tests/vectors/content-version`, written only by the C# reference: the content version of a stored record is the lowercase
//! hex SHA-256 of its persisted encoding (`variants:ruling:write-set-content-version-declared-ownership`). The store reports
//! the version of every record of the committed `store.cc` as `versions.msgpack` says.
use std::path::PathBuf;
use cultcache_rs::{CacheBackingStore, SingleFileMessagePackBackingStore};
fn vectors() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/content-version")
}
#[test]
fn read_reports_every_version_the_reference_reads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.cc");
std::fs::copy(vectors().join("store.cc"), &path).unwrap();
let rows: Vec<(String, String)> = rmp_serde::from_slice(&std::fs::read(vectors().join("versions.msgpack")).unwrap()).unwrap();
assert!(rows.len() >= 2);
let store = SingleFileMessagePackBackingStore::new(&path);
for (key, version) in rows {
let record = store.read(&key).unwrap().unwrap_or_else(|| panic!("no record {key}"));
assert_eq!(record.version, version, "{key}");
}
assert_eq!(store.read("absent").unwrap(), None);
}

View file

@ -1,100 +0,0 @@
//! The Rust CultCache first call and update from docs/start/README.md. The docs show each region between its
//! `snippet` markers verbatim, and `docs_show_the_tested_snippets` fails when they differ.
use std::path::Path;
use cultcache_rs::{CultCache, CultUpdateResult, DatabaseEntry, SingleFileMessagePackBackingStore};
// snippet:begin
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "item")]
struct Item {
#[cultcache(key = 0)]
name: String,
#[cultcache(key = 1)]
category: String,
#[cultcache(key = 2, default)]
value: u32,
}
fn first_call() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("first-call.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Item>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
cache.pull_all_backing_stores()?;
cache.put("item:potion", &Item {
name: "Potion".to_string(),
category: "Consumable".to_string(),
value: 50,
})?; // written to the .cc file before this returns
let mut reopened = CultCache::new();
reopened.register_entry_type::<Item>()?;
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path));
reopened.pull_all_backing_stores()?; // read it back
let potion = reopened.get_required::<Item>("item:potion")?;
assert_eq!(potion.name, "Potion");
assert_eq!(potion.value, 50);
Ok(())
}
// snippet:end
// snippet:update:begin
fn restock(path: &Path) -> anyhow::Result<CultUpdateResult<Item>> {
let mut cache = CultCache::new();
cache.register_entry_type::<Item>()?;
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
cache.pull_all_backing_stores()?;
cache.update::<Item, _>("item:potion", |item| {
// The store's copy of the record, or nothing if it has none; this may run again on a lost race.
let mut item = item.unwrap_or(Item {
name: "Potion".to_string(),
category: "Consumable".to_string(),
value: 0,
});
item.value += 10;
Some(item)
})
}
// snippet:update:end
#[test]
fn first_call_round_trips_through_a_second_cache() {
first_call().unwrap();
}
#[test]
fn restock_twice_reads_twenty_through_a_second_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("restock.cc");
assert_eq!(restock(&path).unwrap().outcome, cultcache_rs::CultUpdateOutcome::Updated);
let second = restock(&path).unwrap();
assert_eq!(second.outcome, cultcache_rs::CultUpdateOutcome::Updated);
assert_eq!(second.document.unwrap().value, 20);
let mut reopened = CultCache::new();
reopened.register_entry_type::<Item>().unwrap();
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
reopened.pull_all_backing_stores().unwrap();
assert_eq!(reopened.get_required::<Item>("item:potion").unwrap().value, 20);
}
/// The docs show the region between `// snippet:<region>begin` and `end` of this file as one fenced block.
fn docs_show_region(region: &str) {
let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap();
let (begin_marker, end_marker) = (format!("// snippet:{region}begin\n"), format!("// snippet:{region}end\n"));
let begin = source.find(&begin_marker).unwrap() + begin_marker.len();
let end = source.find(&end_marker).unwrap();
let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap();
let block = format!("```rust\n{}```", &source[begin..end]);
assert!(docs.contains(&block), "docs/start/README.md no longer shows the {region:?} region of tests/first_call.rs verbatim");
}
#[test]
fn docs_show_the_tested_snippets() {
docs_show_region("");
docs_show_region("update:");
}

View file

@ -1,283 +0,0 @@
//! `tests/vectors/key-identity`, written only by the C# reference: a cache holds one record per key whatever its type, and a
//! store holding a record no registered type claims loads, lists it as foreign and keeps its bytes
//! (`variants:ruling:cache-refuses-retype`, `write-ownership-by-written-type`, `unheld-removal-refused-at-commit`).
//! Every row is walked against this cache: each put row twice, once as `put` and once as a one-entry `put_prepared_batch`.
use std::path::{Path, PathBuf};
use cultcache_rs::{
CacheBackingStore, CultCache, DatabaseEntry, SchemaConflictError, SingleFileMessagePackBackingStore,
};
use serde_json::Value;
// The ids C# writes in the store catalogs and on every record (the vector's README).
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "sha256:b20703a98f49c253898404ab50ba2c630720f0b8a5e1a41614d5650be22799fe", schema = "tests.ki_alpha")]
struct Alpha {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "sha256:dee4b727cf2ee04e61ab635ff0c5f3dca7345a411dc5f1018e583ab16009cf94", schema = "tests.ki_beta")]
struct Beta {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "sha256:fbc26e351e00c9e7185e8de171c7486aba707c229c949f0aae6b889288af5f6d", schema = "tests.ki_gamma")]
struct Gamma {
#[cultcache(key = 0)]
text: String,
}
fn vectors() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/key-identity")
}
/// `[refusal key or nil, held [type, key, text], file [key, schema name] or nil, foreign keys, kept foreign keys]`.
type Expect = (Option<Vec<String>>, Vec<(String, String, String)>, Option<Vec<(String, String)>>, Vec<String>, Vec<String>);
type Row = (String, Vec<String>, Vec<Vec<Value>>, Expect);
fn text(value: &Value) -> &str {
value.as_str().unwrap()
}
fn register(cache: &mut CultCache, names: &[String]) {
for name in names {
match name.as_str() {
"alpha" => drop(cache.register_entry_type::<Alpha>().unwrap()),
"beta" => drop(cache.register_entry_type::<Beta>().unwrap()),
"gamma" => drop(cache.register_entry_type::<Gamma>().unwrap()),
other => panic!("unknown type {other}"),
}
}
}
fn short(entry_type: &str) -> &'static str {
match entry_type {
t if t == Alpha::TYPE => "alpha",
t if t == Beta::TYPE => "beta",
t if t == Gamma::TYPE => "gamma",
other => panic!("unknown entry type {other}"),
}
}
fn put(cache: &mut CultCache, as_batch: bool, kind: &str, key: &str, body: &str) -> anyhow::Result<()> {
macro_rules! write {
($t:ident) => {{
let value = $t { text: body.to_string() };
if as_batch {
let (entry, _) = cache.prepare_entry(key, &value)?;
cache.put_prepared_batch(vec![entry])
} else {
cache.put(key, &value).map(drop)
}
}};
}
match kind {
"alpha" => write!(Alpha),
"beta" => write!(Beta),
"gamma" => write!(Gamma),
other => panic!("unknown type {other}"),
}
}
fn delete(cache: &mut CultCache, kind: &str, key: &str) -> anyhow::Result<bool> {
match kind {
"alpha" => cache.delete::<Alpha>(key),
"beta" => cache.delete::<Beta>(key),
"gamma" => cache.delete::<Gamma>(key),
other => panic!("unknown type {other}"),
}
}
fn open_cache(registered: &[String], path: Option<&Path>) -> anyhow::Result<CultCache> {
let mut cache = CultCache::new();
register(&mut cache, registered);
if let Some(path) = path {
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path))?;
cache.pull_all_backing_stores()?;
}
Ok(cache)
}
/// The records a store file holds, read by a fresh store: (key, schema name, schema id, stored-at, payload).
fn file_records(path: &Path) -> Vec<(String, String, Option<String>, String, Vec<u8>)> {
let mut records: Vec<_> = SingleFileMessagePackBackingStore::new(path)
.pull_all()
.unwrap()
.into_iter()
.map(|entry| (entry.key, entry.r#type, entry.schema_id, entry.stored_at, entry.payload))
.collect();
records.sort_by(|left, right| left.0.encode_utf16().cmp(right.0.encode_utf16()));
records
}
fn walk(row: &Row, as_batch: bool) -> Result<(), String> {
let (label, registered, ops, expect) = row;
let dir = tempfile::tempdir().unwrap();
let mut path: Option<PathBuf> = None;
let mut before = Vec::new();
let mut cache: Option<CultCache> = None;
let mut refused: Option<anyhow::Error> = None;
let mut refused_key: Option<String> = None;
let mut shared_key: Option<String> = None;
let mut wrote = false;
for (index, op) in ops.iter().enumerate() {
let last = index == ops.len() - 1;
let result: anyhow::Result<()> = match text(&op[0]) {
"open" => {
drop(cache.take());
match &op[1] {
Value::String(file) if file == "memory" => open_cache(registered, None).map(|opened| cache = Some(opened)),
file => {
let fresh = dir.path().join("store.cc");
if let Value::String(file) = file {
std::fs::copy(vectors().join(file), &fresh).unwrap();
}
before = if fresh.exists() { file_records(&fresh) } else { Vec::new() };
let opened = open_cache(registered, Some(&fresh));
path = Some(fresh);
opened.map(|opened| cache = Some(opened))
}
}
}
"reopen" => {
drop(cache.take());
open_cache(registered, path.as_deref()).map(|opened| cache = Some(opened))
}
"attach" => {
let file = text(&op[1]);
let attached = dir.path().join(file);
std::fs::copy(vectors().join(file), &attached).unwrap();
let held: Vec<String> = cache.as_ref().unwrap().snapshot().into_iter().map(|entry| entry.key).collect();
let shared: Vec<String> = file_records(&attached).into_iter().map(|record| record.0).filter(|key| held.contains(key)).collect();
let home: Vec<String> = op[2].as_array().unwrap().iter().map(|name| short_to_type(text(name)).to_string()).collect();
let cache = cache.as_mut().unwrap();
let attach = cache
.add_backing_store(SingleFileMessagePackBackingStore::new(&attached), home)
.and_then(|()| cache.pull_all_backing_stores());
if attach.is_err() && last && shared.len() == 1 {
refused_key = Some(shared[0].clone());
shared_key = refused_key.clone();
assert_eq!(std::fs::read(&attached).unwrap(), std::fs::read(vectors().join(file)).unwrap(), "{label}: a refused attach leaves the store as it was");
}
attach
}
"put" => {
wrote = true;
put(cache.as_mut().unwrap(), as_batch, text(&op[1]), text(&op[2]), text(&op[3]))
}
"delete" => delete(cache.as_mut().unwrap(), text(&op[1]), text(&op[2])).map(drop),
other => panic!("{label}: unknown op {other}"),
};
match result {
Ok(()) => {}
Err(error) if last => refused = Some(error),
Err(error) => return Err(format!("{label}: op {index} failed: {error:#}")),
}
}
if !wrote && as_batch {
return Ok(()); // not a write row: the batch variant is the same walk
}
let Some(cache) = cache.as_ref() else {
return Err(format!("{label} (batch {as_batch}): the cache did not open: {:#}", refused.unwrap()));
};
let last = ops.last().unwrap();
if let Some(error) = &refused {
if let Some(conflict) = error.downcast_ref::<SchemaConflictError>() {
refused_key = Some(conflict.record_key.clone());
let named = if text(&last[0]) == "attach" { shared_key.clone().unwrap() } else { text(&last[2]).to_string() };
assert_eq!(named, conflict.record_key, "{label}: the refusal names the key written, removed or shared");
}
let key = refused_key.clone().unwrap_or_default();
let message = format!("{error:#}");
let (all, delimited) = occurrences(&message, &key);
assert!(!key.is_empty() && delimited > 0, "{label}: the refusal names the key as a token: {message}");
if text(&last[0]) == "attach" {
assert_eq!(all, delimited, "{label}: the key occurs in the refusal only as a delimited token: {message}");
}
}
let mut held: Vec<(String, String, String)> = cache
.snapshot()
.into_iter()
.map(|entry| {
let body = match short(&entry.r#type) {
"alpha" => cache.get::<Alpha>(&entry.key).unwrap().unwrap().text,
"beta" => cache.get::<Beta>(&entry.key).unwrap().unwrap().text,
_ => cache.get::<Gamma>(&entry.key).unwrap().unwrap().text,
};
(short(&entry.r#type).to_string(), entry.key, body)
})
.collect();
held.sort_by(|a, b| (a.0.as_str(), a.1.encode_utf16().collect::<Vec<_>>()).cmp(&(b.0.as_str(), b.1.encode_utf16().collect::<Vec<_>>())));
let foreign: Vec<String> = cache.foreign_records().into_iter().map(|record| record.key).collect();
let (file, kept) = match &path {
None => (None, Vec::new()),
Some(path) => {
let after = file_records(path);
let kept = foreign
.iter()
.filter(|key| {
let original = before.iter().find(|record| &record.0 == *key);
let now = after.iter().find(|record| &record.0 == *key);
original.is_some() && original.map(|r| (&r.2, &r.3, &r.4)) == now.map(|r| (&r.2, &r.3, &r.4))
})
.cloned()
.collect();
// This writer names a catalog entry by the record's type, which for these types is the id; C# names it by the
// schema name. Which type the record at a key is, is the verdict, so the id reads as its schema name.
let name = |described: String| match described.as_str() {
t if t == Alpha::TYPE => Alpha::SCHEMA_NAME.to_string(),
t if t == Beta::TYPE => Beta::SCHEMA_NAME.to_string(),
t if t == Gamma::TYPE => Gamma::SCHEMA_NAME.to_string(),
_ => described,
};
(Some(after.into_iter().map(|record| (record.0, name(record.1))).collect::<Vec<_>>()), kept)
}
};
let observed: Expect = (refused_key.map(|key| vec![key]), held, file, foreign, kept);
if &observed == expect { Ok(()) } else { Err(format!("{label} (batch {as_batch}): committed {expect:?} but this cache answers {observed:?}")) }
}
/// How often `key` occurs in `message`, and how often as a delimited token (not a piece of a longer word, schema name or id).
fn occurrences(message: &str, key: &str) -> (usize, usize) {
let word = |c: Option<char>| c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
let found: Vec<usize> = message.match_indices(key).map(|(at, _)| at).collect();
let delimited = found
.iter()
.filter(|at| !word(message[..**at].chars().next_back()) && !word(message[**at + key.len()..].chars().next()))
.count();
(found.len(), delimited)
}
fn short_to_type(name: &str) -> &'static str {
match name {
"alpha" => Alpha::TYPE,
"beta" => Beta::TYPE,
"gamma" => Gamma::TYPE,
other => panic!("unknown type {other}"),
}
}
#[test]
fn key_identity_rows_hold_their_verdicts() {
let bytes = std::fs::read(vectors().join("rows.msgpack")).unwrap();
let rows: Vec<Row> = rmp_serde::from_slice(&bytes).unwrap();
let mut walked = 0;
let mut failed = Vec::new();
for row in &rows {
let mut ok = true;
for as_batch in [false, true] {
if let Err(message) = walk(row, as_batch) {
failed.push(message);
ok = false;
}
}
walked += usize::from(ok);
}
assert!(failed.is_empty(), "{}", failed.join("\n"));
assert_eq!(walked, 12, "rows walked");
}

View file

@ -1,390 +0,0 @@
//! Lookups parity with the C# reference: `GetByName`, `GetByIndex` and `GetGlobal`
//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces.
use cultcache_rs::{
CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry,
SingleFileMessagePackBackingStore,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "item")]
struct Item {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1)]
category: String,
#[cultcache(key = 2, index = "sku")]
code: Option<String>,
#[cultcache(key = 3, default)]
value: u32,
}
fn item(name: &str, category: &str, code: Option<&str>) -> Item {
Item { name: name.into(), category: category.into(), code: code.map(Into::into), value: 0 }
}
/// Same names and values as `Item`, a different type: lookups never cross types.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "gadget")]
struct Gadget {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1, index)]
serial: String,
}
/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "zed")]
struct Zed {
#[cultcache(key = 0, name)]
name: String,
#[cultcache(key = 1)]
category: String,
#[cultcache(key = 2, index = "sku")]
code: Option<String>,
#[cultcache(key = 3, default)]
value: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings", schema = "tests.settings", global)]
struct Settings {
#[cultcache(key = 0)]
value: String,
}
fn settings(value: &str) -> Settings {
Settings { value: value.into() }
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "plain")]
struct Plain {
#[cultcache(key = 0)]
value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "numbered")]
struct Numbered {
#[cultcache(key = 0, name)]
id: u32,
#[cultcache(key = 1, index)]
badge: u64,
}
fn cache() -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<Item>().unwrap();
cache.register_entry_type::<Gadget>().unwrap();
cache.register_entry_type::<Zed>().unwrap();
cache.register_entry_type::<Settings>().unwrap();
cache.register_entry_type::<Plain>().unwrap();
cache.register_entry_type::<Numbered>().unwrap();
cache
}
fn reopened(path: &std::path::Path) -> CultCache {
let mut reopened = cache();
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
reopened.pull_all_backing_stores().unwrap();
reopened
}
fn stored_cache(path: &std::path::Path) -> CultCache {
let mut stored = cache();
stored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap();
stored.pull_all_backing_stores().unwrap();
stored
}
// The C# FirstCall snippet: a record written to a .cc file is found by its [CultName] through a second cache.
#[test]
fn a_record_is_found_by_name_and_by_index_after_a_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lookups.cc");
let mut written = stored_cache(&path);
written.put("item:potion", &Item { value: 50, ..item("Potion", "Consumable", Some("P-1")) }).unwrap();
let reopened = reopened(&path);
let by_name = reopened.get_by_name::<Item>("Potion").unwrap().unwrap();
assert_eq!(by_name.value, 50);
assert_eq!(reopened.get_by_index::<Item>("sku", "P-1").unwrap(), Some(by_name));
}
#[test]
fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() {
let mut cache = cache();
cache.put("item:a", &item("A", "cat", Some("a-1"))).unwrap();
assert_eq!(cache.get_by_name::<Item>("B").unwrap(), None);
assert_eq!(cache.get_by_name::<Item>("a").unwrap(), None, "names compare ordinally");
assert_eq!(cache.get_by_index::<Item>("sku", "other").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("nosuchalias", "a-1").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("category", "cat").unwrap(), None, "category is a grouping, not an index");
assert_eq!(cache.get_by_index::<Item>("code", "a-1").unwrap(), None, "the alias is sku, not the member name");
let bare = CultCache::new();
assert!(bare.get_by_name::<Item>("A").is_err());
assert!(bare.get_by_index::<Item>("sku", "a-1").is_err());
assert!(bare.get_global::<Settings>().is_err());
}
#[test]
fn an_index_without_an_alias_is_named_by_its_member() {
let mut cache = cache();
cache.put("numbered:1", &Numbered { id: 1, badge: 7 }).unwrap();
assert!(cache.get_by_index::<Numbered>("badge", "7").unwrap().is_some());
}
// A blank name is no name; a blank index value is not indexed; a nil member holds no value.
#[test]
fn blank_and_nil_values_are_not_found() {
let mut cache = cache();
cache.put("item:blank", &item("", " ", Some(" "))).unwrap();
assert_eq!(cache.get_by_name::<Item>("").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", " ").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "").unwrap(), None);
// Only emptiness voids a name: whitespace is a name, as in C# (Length > 0).
cache.put("item:space", &item(" ", "x", None)).unwrap();
assert_eq!(cache.get_by_name::<Item>(" ").unwrap().unwrap().category, "x");
}
#[test]
fn lookups_never_cross_types() {
let mut cache = cache();
cache.put("item:a", &item("same", "same", Some("same"))).unwrap();
cache.put("gadget:a", &Gadget { name: "same".into(), serial: "same".into() }).unwrap();
cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap();
assert_eq!(cache.get_by_name::<Gadget>("same").unwrap().unwrap().name, "same");
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("same").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "same").unwrap(), None);
assert!(cache.get_by_name::<Gadget>("same").unwrap().is_some());
assert!(cache.get_by_index::<Gadget>("serial", "same").unwrap().is_some());
}
#[test]
fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() {
let mut cache = cache();
cache.put("item:a", &item("old", "cat", Some("c1"))).unwrap();
cache.put("item:a", &item("new", "cat", Some("c2"))).unwrap();
assert_eq!(cache.get_by_name::<Item>("old").unwrap(), None, "a stale name is not found");
assert_eq!(cache.get_by_index::<Item>("sku", "c1").unwrap(), None, "a stale value is not found");
assert!(cache.get_by_name::<Item>("new").unwrap().is_some());
assert!(cache.get_by_index::<Item>("sku", "c2").unwrap().is_some());
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("new").unwrap(), None);
assert_eq!(cache.get_by_index::<Item>("sku", "c2").unwrap(), None);
}
#[test]
fn non_string_members_are_found_by_their_text() {
let mut cache = cache();
let badge = 161_u64;
cache.put("numbered:1", &Numbered { id: 42, badge }).unwrap();
assert!(cache.get_by_name::<Numbered>("42").unwrap().is_some());
assert!(cache.get_by_index::<Numbered>("badge", "161").unwrap().is_some());
}
#[test]
fn records_sharing_a_name_find_the_smallest_key() {
let mut cache = cache();
cache.put("item:b", &Item { value: 2, ..item("twin", "c", None) }).unwrap();
cache.put("item:a", &Item { value: 1, ..item("twin", "c", None) }).unwrap();
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 1);
cache.delete::<Item>("item:a").unwrap();
assert_eq!(cache.get_by_name::<Item>("twin").unwrap().unwrap().value, 2);
}
// BackingStoreTests.HydratesPersistedGlobal: opening a store invents no global; a written one survives a reopen.
#[test]
fn a_global_is_none_until_written_and_survives_a_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("global.cc");
{
let mut seed = stored_cache(&path);
assert_eq!(seed.get_global::<Settings>().unwrap(), None, "opening a store invented a global");
seed.put("global:settings", &settings("durable authority")).unwrap();
assert_eq!(seed.get_global::<Settings>().unwrap(), Some(settings("durable authority")));
}
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("durable authority")));
}
/// `Settings` as an ordinary type: the same type id and schema, no global rule.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "settings", schema = "tests.settings")]
struct LooseSettings {
#[cultcache(key = 0)]
value: String,
}
// Declaring a held type global judges the records it holds: two are already a second, so the registration is
// refused and the type stays ordinary.
#[test]
fn registering_a_type_global_over_two_held_records_is_refused_and_changes_nothing() {
let mut cache = CultCache::new();
cache.register_entry_type::<LooseSettings>().unwrap();
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
cache.put("b", &LooseSettings { value: "two".into() }).unwrap();
let refusal = cache.register_entry_type::<Settings>().unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
cache.put("c", &LooseSettings { value: "three".into() }).unwrap();
assert_eq!(cache.get_all_with_keys::<LooseSettings>().unwrap().len(), 3, "still ordinary");
}
// One held record may become the global; a second is then refused as a second global.
#[test]
fn registering_a_type_global_over_one_held_record_is_admitted() {
let mut cache = CultCache::new();
cache.register_entry_type::<LooseSettings>().unwrap();
cache.put("a", &LooseSettings { value: "one".into() }).unwrap();
cache.register_entry_type::<Settings>().unwrap();
let refusal = cache.put("b", &Settings { value: "two".into() }).unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
}
/// A global with an indexed member, and the same type and schema declaring that member as an integer.
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
struct IndexedGlobal {
#[cultcache(key = 0, index = "V")]
v: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "indexed_global", schema = "tests.indexed_global", global)]
struct RetypedGlobal {
#[cultcache(key = 0, index = "V")]
v: i64,
}
// A refused re-registration over an already-global type leaves it global: the rule keeps refusing a second record.
#[test]
fn a_refused_reregistration_leaves_a_global_type_global() {
let mut cache = CultCache::new();
cache.register_entry_type::<IndexedGlobal>().unwrap();
cache.put("a", &IndexedGlobal { v: "x".into() }).unwrap();
assert!(cache.register_entry_type::<RetypedGlobal>().is_err(), "the held text cannot be decoded as an integer");
let refusal = cache.put("b", &IndexedGlobal { v: "y".into() }).unwrap_err();
assert!(refusal.downcast_ref::<CultCacheSecondGlobal>().is_some(), "{refusal:#}");
assert_eq!(cache.get_global::<IndexedGlobal>().unwrap(), Some(IndexedGlobal { v: "x".into() }));
assert_eq!(cache.get_by_index::<IndexedGlobal>("V", "x").unwrap(), cache.get::<IndexedGlobal>("a").unwrap());
}
#[test]
fn a_type_that_is_not_a_global_has_no_global() {
let mut cache = cache();
cache.put("plain:one", &Plain { value: "v".into() }).unwrap();
assert_eq!(cache.get_global::<Plain>().unwrap(), None);
}
#[test]
fn a_second_global_under_another_key_is_refused_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("second.cc");
let mut cache = stored_cache(&path);
cache.put("g1", &settings("one")).unwrap();
let refusal = cache.put("g2", &settings("two")).unwrap_err();
let second = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
assert_eq!((second.existing_key.as_str(), second.key.as_str()), ("g1", "g2"));
assert!(refusal.to_string().contains("is a global and already has record g1; g2 would be a second."));
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
assert_eq!(reopened(&path).get_global::<Settings>().unwrap(), Some(settings("one")), "nothing reached the store");
// The same key is the same record, not a second.
cache.put("g1", &settings("one again")).unwrap();
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one again")));
}
// RetypeEvictionTests.EvictingAGlobalFreesItsSlot: removing the global frees the slot for another key.
#[test]
fn removing_a_global_frees_its_slot() {
let mut cache = cache();
cache.put("g1", &settings("one")).unwrap();
assert!(cache.put("g2", &settings("two")).is_err());
cache.delete::<Settings>("g1").unwrap();
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
cache.put("g2", &settings("two")).unwrap();
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("two")));
}
#[test]
fn every_write_path_refuses_a_second_global() {
let mut cache = cache();
cache.put("g1", &settings("one")).unwrap();
let (second, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
assert!(cache.put_envelope::<Settings>(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
assert!(cache.put_raw_envelope(second.clone()).unwrap_err().is::<CultCacheSecondGlobal>());
let (plain, _) = cache.prepare_entry("plain:1", &Plain { value: "v".into() }).unwrap();
assert!(cache.put_prepared_batch(vec![plain, second]).unwrap_err().is::<CultCacheSecondGlobal>());
assert_eq!(cache.get::<Plain>("plain:1").unwrap(), None, "a refused batch lands none of it");
assert_eq!(cache.get_global::<Settings>().unwrap(), Some(settings("one")));
}
#[test]
fn a_batch_that_stages_two_globals_is_refused_whole() {
let mut cache = cache();
let (one, _) = cache.prepare_entry("g1", &settings("one")).unwrap();
let (two, _) = cache.prepare_entry("g2", &settings("two")).unwrap();
assert!(cache.put_prepared_batch(vec![one, two]).unwrap_err().is::<CultCacheSecondGlobal>());
assert_eq!(cache.get_global::<Settings>().unwrap(), None);
}
// StoreRoutingTests: a store holding two globals is refused on load rather than answering one of them.
#[test]
fn loading_a_store_that_holds_two_globals_is_refused() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("two.cc");
let source = cache();
let mut store = SingleFileMessagePackBackingStore::new(&path);
for (key, value) in [("g1", "one"), ("g2", "two")] {
let (entry, _) = source.prepare_entry(key, &settings(value)).unwrap();
store.push(&entry).unwrap();
}
let mut loading = cache();
loading.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>());
assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing");
}
// load_soa installs a whole table at once, so the one-global rule has to hold there too: a table holding two
// records of a global type is refused whole and the prior view survives.
#[test]
fn loading_an_soa_table_that_holds_two_globals_is_refused() {
let source = cache();
let (first, _) = source.prepare_entry("g1", &settings("one")).unwrap();
let (second, _) = source.prepare_entry("g2", &settings("two")).unwrap();
let mut loading = cache();
loading.put("g0", &settings("prior")).unwrap();
loading.put("plain:1", &Plain { value: "prior".into() }).unwrap();
let refusal = loading
.load_soa(CultCacheSoaTable::from_envelopes(vec![first.clone(), second]))
.unwrap_err();
let second_global = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
let mut named = [second_global.existing_key.as_str(), second_global.key.as_str()];
named.sort();
assert_eq!(named, ["g1", "g2"], "the refusal names both records");
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("prior")), "a refused load keeps the prior view");
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), Some(Plain { value: "prior".into() }));
// One global in the table is the rule's own case, not a second.
loading.load_soa(CultCacheSoaTable::from_envelopes(vec![first])).unwrap();
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("one")));
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), None, "a load replaces the view");
}

View file

@ -1,165 +0,0 @@
//! A consumer of a single-file store reads the schema version a record was published under from the store's catalog, through the
//! public surface only: `pull_all`, `pull_schema_catalog` and `resolve_schema_entry`
//! (`cultlib-gaps:follow_up:rs-snapshot-schema-version`).
use std::path::PathBuf;
use cultcache_rs::{
CacheBackingStore, CultCache, DatabaseEntry, SchemaCatalogEntry, SingleFileMessagePackBackingStore, resolve_schema_entry,
};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.sc_one", schema = "tests.sc_one")]
struct One {
#[cultcache(key = 0)]
text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.sc_two", schema = "tests.sc_two")]
struct Two {
#[cultcache(key = 0)]
text: String,
}
fn vectors(folder: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors").join(folder)
}
/// The version a store publishes for each record, in key order: `(key, schema name, schema version)`.
fn versions(store: &SingleFileMessagePackBackingStore) -> Vec<(String, String, String)> {
let catalog = store.pull_schema_catalog().unwrap();
let mut rows: Vec<_> = store
.pull_all()
.unwrap()
.iter()
.map(|envelope| {
let id = envelope.schema_id.as_deref().unwrap();
let entry = resolve_schema_entry(&catalog, id).unwrap_or_else(|| panic!("no entry for {id}"));
(envelope.key.clone(), entry.schema_name.clone(), entry.schema_version.clone())
})
.collect();
rows.sort();
rows
}
fn copy(from: PathBuf) -> (tempfile::TempDir, SingleFileMessagePackBackingStore) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.cc");
std::fs::copy(from, &path).unwrap();
(dir, SingleFileMessagePackBackingStore::new(&path))
}
#[test]
fn a_consumer_reads_the_version_of_each_type_a_rust_store_holds() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<One>().unwrap();
cache.register_entry_type::<Two>().unwrap();
cache.add_backing_store(SingleFileMessagePackBackingStore::new(&path), ["tests.sc_one", "tests.sc_two"]).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put("a", &One { text: "1".into() }).unwrap();
cache.put("b", &Two { text: "2".into() }).unwrap();
drop(cache);
let consumer = SingleFileMessagePackBackingStore::new(&path);
assert_eq!(
versions(&consumer),
vec![
("a".to_string(), "tests.sc_one".to_string(), "tests.sc_one.v1".to_string()),
("b".to_string(), "tests.sc_two".to_string(), "tests.sc_two.v1".to_string()),
]
);
}
#[test]
fn an_id_only_an_entry_lists_as_compatible_resolves_to_that_entry() {
let (_dir, store) = copy(vectors("document-variants-c2a/readability/compatible-id-only-v3.bin"));
let catalog = store.pull_schema_catalog().unwrap();
assert_eq!(catalog.len(), 1);
assert_eq!(catalog[0].schema_id, "vectors.item.next");
assert_eq!(catalog[0].compatible_schema_ids, vec!["vectors.item".to_string()]);
let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap();
assert_eq!(entry.schema_id, "vectors.item.next");
assert_eq!(entry.schema_version, "vectors.item.v1");
assert_eq!(resolve_schema_entry(&catalog, "vectors.nowhere"), None);
}
#[test]
fn an_id_an_entry_owns_resolves_to_its_owner_not_to_an_entry_listing_it() {
for vector in ["own-id-over-compatible-v3.bin", "compatible-before-owner-v3.bin"] {
let (_dir, store) = copy(vectors("document-variants-c2a/readability").join(vector));
let catalog = store.pull_schema_catalog().unwrap();
assert_eq!(catalog.len(), 2, "{vector}");
let entry = resolve_schema_entry(&catalog, "vectors.item").unwrap();
assert_eq!((entry.schema_id.as_str(), entry.schema_version.as_str()), ("vectors.item", "vectors.item.v1"), "{vector}");
}
}
#[test]
fn a_legacy_array_and_an_absent_file_publish_no_catalog() {
let (_dir, store) = copy(vectors("document-variants-c2a/readability/legacy-envelopes.bin"));
assert!(!store.pull_all().unwrap().is_empty());
assert_eq!(store.pull_schema_catalog().unwrap(), Vec::<SchemaCatalogEntry>::new());
let dir = tempfile::tempdir().unwrap();
let absent = SingleFileMessagePackBackingStore::new(dir.path().join("none.cc"));
assert_eq!(absent.pull_schema_catalog().unwrap(), Vec::<SchemaCatalogEntry>::new());
assert!(!dir.path().join("none.cc").exists() && !dir.path().join("none.cc.lock").exists(), "reading an absent store creates nothing");
}
// `tests/vectors/key-identity/foreign-store.cc` is written by the C# reference: two types, each published with its version.
#[test]
fn a_store_the_csharp_reference_wrote_gives_its_versions() {
let (_dir, store) = copy(vectors("key-identity/foreign-store.cc"));
assert_eq!(
versions(&store),
vec![
("j".to_string(), "tests.ki_alpha".to_string(), "tests.ki_alpha.v1".to_string()),
("k".to_string(), "tests.ki_gamma".to_string(), "tests.ki_gamma.v1".to_string()),
]
);
let catalog = store.pull_schema_catalog().unwrap();
assert_eq!(catalog[0].content_hash, "sha256:02f0c8b7ea07c11705c1365fc29136ada08c8076209cacfb8d5526229461ce7a");
}
type Entry = (&'static str, &'static str, &'static str, &'static str, &'static str, Vec<&'static str>, Vec<()>);
// A hand-built v3 store whose versions are not the `<name>.v1` the writers above publish, so a version read from anywhere but the
// version slot cannot pass. `shared` is listed by two entries (the first lister wins); `id.c` is owned by one entry and listed by
// an earlier one (the owner wins).
#[test]
fn distinct_versions_come_through_with_the_decoders_resolution() {
let catalog: Vec<Entry> = vec![
("id.a", "N.a", "9.9.9", "hash-a", "{}", vec!["id.a", "shared"], vec![]),
("id.b", "N.b", "7.0", "hash-b", "{}", vec!["id.b", "shared", "id.c"], vec![]),
("id.c", "N.c", "3", "hash-c", "{}", vec!["id.c"], vec![]),
];
let record = |key: &str, id: &str| (key.to_string(), id.to_string(), "2026-11-13T00:00:00Z".to_string(), b"x".to_vec());
let records = vec![record("r1", "shared"), record("r2", "id.c"), record("r3", "id.b"), record("r4", "id.a")];
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("store.cc");
std::fs::write(&path, rmp_serde::to_vec(&("cultcache.store.v3", catalog, records)).unwrap()).unwrap();
let store = SingleFileMessagePackBackingStore::new(&path);
let listed = store.pull_schema_catalog().unwrap();
assert_eq!(listed.iter().map(|e| e.schema_version.as_str()).collect::<Vec<_>>(), vec!["9.9.9", "7.0", "3"]);
assert_eq!(listed[1].content_hash, "hash-b");
assert_eq!(listed[1].compatible_schema_ids, vec!["id.b", "shared", "id.c"]);
let got: Vec<(String, String, String)> = {
let mut rows: Vec<_> = store
.pull_all()
.unwrap()
.iter()
.map(|e| {
let entry = resolve_schema_entry(&listed, e.schema_id.as_deref().unwrap()).unwrap();
(e.key.clone(), entry.schema_name.clone(), entry.schema_version.clone())
})
.collect();
rows.sort();
rows
};
let row = |k: &str, n: &str, v: &str| (k.to_string(), n.to_string(), v.to_string());
assert_eq!(got, vec![row("r1", "N.a", "9.9.9"), row("r2", "N.c", "3"), row("r3", "N.b", "7.0"), row("r4", "N.a", "9.9.9")]);
}

View file

@ -1,541 +0,0 @@
//! A record whose payload carries unread slots at any depth is read-only: the C# reference's union-parity vectors
//! (README "Extra trailing slot" and "Slot stores") walked through the cache. This runtime has no compatible schema
//! ids, so every mark is unconsented and no `explained` outcome can arise.
mod common;
use std::path::Path;
use common::*;
use cultcache_rs::CultCache;
use cultcache_rs::CultCacheEnvelope;
use cultcache_rs::DatabaseEntry;
use cultcache_rs::SchemaConflictError;
use cultcache_rs::SingleFileMessagePackBackingStore;
use cultcache_rs::UnreadSlots;
use cultcache_rs::decode_payload_marked;
/// A row of `slot-outcomes.msgpack`: store, key, writable, explained, type, slots, declared, occurrences, flattenRefusedBy.
type SlotRow = (String, String, bool, Option<bool>, Option<String>, Option<usize>, Option<usize>, Option<usize>, Option<String>);
const SLOT_VALUE: &str = "extra-slot";
/// `tests.slot_unit`, a document `[Name, Outcome: UnitOutcome]`.
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "tests.slot_unit", schema = "tests.slot_unit.v1")]
struct SlotUnit {
#[cultcache(key = 0)]
name: String,
#[cultcache(key = 1)]
outcome: UnitOutcome,
}
/// Two declared slots, the second defaulted: a payload of one slot is short, not marked.
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "tests.shortened", schema = "tests.shortened.v1")]
struct Shortened {
#[cultcache(key = 0)]
id: String,
#[cultcache(key = 1, default)]
note: String,
}
fn registered() -> CultCache {
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.register_entry_type::<SlotUnit>().unwrap();
cache
}
/// Any MessagePack value a store holds, enough to drop one record from a store and write the rest back.
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
enum Packed {
Nil(()),
Bool(bool),
Int(i64),
Text(String),
Bin(#[serde(with = "serde_bytes")] Vec<u8>),
Array(Vec<Packed>),
}
/// The committed store without its `marked-variant` record. A variant is a five-slot record, which this runtime's
/// store reader refuses whole (`the_variant_row_...`), so the other rows are walked from a copy without it.
fn without_variant_row(store: &[u8]) -> Vec<u8> {
let Packed::Array(mut parts) = rmp_serde::from_slice(store).unwrap() else { panic!("a store is an array") };
let Packed::Array(records) = parts.last_mut().unwrap() else { panic!("a store ends in its records") };
let before = records.len();
records.retain(|record| !matches!(record, Packed::Array(fields) if matches!(fields.first(), Some(Packed::Text(key)) if key == "marked-variant")));
assert_eq!(records.len(), before - 1);
rmp_serde::to_vec(&parts).unwrap()
}
/// A copy of a committed store, opened with both types registered; the vector itself is never written. The slot store
/// is read without its variant row.
fn open_copy(file: &str, dir: &Path) -> (CultCache, std::path::PathBuf) {
let path = dir.join(file);
let bytes = if file == "extra-slot-store.cc" { without_variant_row(&vector(file)) } else { vector(file) };
std::fs::write(&path, bytes).unwrap();
let mut cache = registered();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
(cache, path)
}
fn envelope(key: &str, payload: Vec<u8>) -> CultCacheEnvelope {
CultCacheEnvelope {
key: key.into(),
r#type: UnionSurface::TYPE.into(),
payload,
stored_at: "2026-01-01T00:00:00Z".into(),
schema_id: None,
}
}
fn conflict(error: &anyhow::Error) -> &SchemaConflictError {
error.downcast_ref::<SchemaConflictError>().unwrap_or_else(|| panic!("not a schema conflict: {error:#}"))
}
fn mark(type_name: &str, slot_count: usize, declared_count: usize, occurrences: usize) -> UnreadSlots {
UnreadSlots { type_name: type_name.into(), slot_count, declared_count, occurrences }
}
/// The marks the README table pins, by `detail`. The reference names an arm by its class; here an arm is the
/// `...Arm` struct the union wraps.
fn expected_mark(detail: &str) -> UnreadSlots {
match detail {
"record" | "depth" => mark("UnionSurface", 8, 7, 1),
"nested-record" => mark("Composition", 3, 2, 1),
"arm" => mark("BoxArm", 3, 2, 1),
"list-element" => mark("TextArm", 3, 2, 1),
other => panic!("unknown detail {other}"),
}
}
fn assert_refused_with(error: &anyhow::Error, key: &str, expected: &UnreadSlots) {
let refusal = conflict(error);
assert_eq!(refusal.unread_slots.as_ref(), Some(expected));
assert_eq!(refusal.record_key, key);
assert_eq!(refusal.schema_names.len(), 1);
let message = error.to_string();
assert!(message.contains(&expected.type_name), "{message}");
assert!(message.contains(&expected.slot_count.to_string()) && message.contains(&expected.declared_count.to_string()), "{message}");
assert!(!message.contains(SLOT_VALUE), "a refusal echoed the slot value: {message}");
}
#[test]
fn every_read_only_vector_decodes_marked_and_is_not_writable() {
let rows: Vec<(String, String, String, Option<i64>, String)> = rmp_serde::from_slice(&vector("outcomes.msgpack")).unwrap();
assert_eq!(rows.len(), 9);
let bounds: Vec<serde_json::Value> = rmp_serde::from_slice(&vector("bounds-outcomes.msgpack")).unwrap();
let mut cases: Vec<(String, String)> = rows
.into_iter()
.filter(|(_, outcome, ..)| outcome == "read-only")
.map(|(file, _, _, _, detail)| (file, detail))
.collect();
for entry in &bounds[1..] {
let fields: Vec<&str> = entry.as_array().unwrap().iter().filter_map(|field| field.as_str()).collect();
if fields[1] == "read-only" {
cases.push((fields[0].to_string(), fields[3].to_string()));
}
}
assert_eq!(cases.len(), 5);
for (file, detail) in cases {
let payload = vector(&file);
let (value, found) = decode_payload_marked::<UnionSurface>(&payload).unwrap_or_else(|error| panic!("{file}: {error:#}"));
assert_eq!(value, constructed(), "{file}");
assert_eq!(found, Some(expected_mark(&detail)), "{file}");
let mut cache = registered();
let dir = tempfile::tempdir().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc"))).unwrap();
cache.pull_all_backing_stores().unwrap();
cache.put_raw_envelope(envelope("k", payload.clone())).unwrap();
assert_eq!(cache.get::<UnionSurface>("k").unwrap(), Some(constructed()), "{file}");
assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap(), "{file}");
assert!(cache.is_writable(UnionSurface::TYPE, "unheld").unwrap(), "{file}");
let error = cache.put("k", &constructed()).unwrap_err();
assert_refused_with(&error, "k", &expected_mark(&detail));
}
}
#[test]
fn clean_and_short_payloads_are_writable_and_unmarked() {
let (value, found) = decode_payload_marked::<UnionSurface>(&vector("union-surface.msgpack")).unwrap();
assert_eq!((value, found), (constructed(), None));
// A payload short of its declared slots keeps its defaults and does not mark.
let (value, found) = decode_payload_marked::<Shortened>(&rmp_serde::to_vec(&("only",)).unwrap()).unwrap();
assert_eq!((value.id.as_str(), value.note.as_str(), found), ("only", "", None));
}
#[test]
fn a_payload_past_the_nesting_limit_is_refused_never_marked() {
for file in ["depth-skipped-past-limit.msgpack", "depth-past-limit.msgpack"] {
let error = decode_payload_marked::<UnionSurface>(&vector(file)).unwrap_err();
assert!(error.to_string().contains("the limit is 256"), "{file}: {error:#}");
}
}
#[test]
fn a_plain_serde_decode_marks_nothing_and_does_not_leak_a_scope() {
// No scope is open: the visitors note into nothing.
let value: UnionSurface = rmp_serde::from_slice(&vector("extra-slot-record.msgpack")).unwrap();
assert_eq!(value, constructed());
// A marked decode followed by a clean one: the first mark does not survive into the second.
assert!(decode_payload_marked::<UnionSurface>(&vector("extra-slot-record.msgpack")).unwrap().1.is_some());
assert_eq!(decode_payload_marked::<UnionSurface>(&vector("union-surface.msgpack")).unwrap().1, None);
}
#[test]
fn the_slot_store_rows_walk_through_the_cache() {
let rows: Vec<SlotRow> =
rmp_serde::from_slice(&vector("slot-outcomes.msgpack")).unwrap();
assert_eq!(rows.len(), 8);
let dir = tempfile::tempdir().unwrap();
let mut walked = 0;
for (store, key, writable, _explained, type_name, slots, declared, occurrences, flatten_refused_by) in rows {
// The variant row is a record this runtime cannot hold: see the variant test. The consent store's `writable` and
// `explained` answer the compatible-id declaration, which this runtime has none of; its marks are walked below.
if flatten_refused_by.is_some() || store == "extra-slot-consent.cc" {
continue;
}
let case = dir.path().join(&key);
std::fs::create_dir_all(&case).unwrap();
let (mut cache, _) = open_copy(&store, &case);
let type_id = if key == "unit-arm" { SlotUnit::TYPE } else { UnionSurface::TYPE };
assert_eq!(cache.is_writable(type_id, &key).unwrap(), writable, "{store} {key}");
let removal = if key == "unit-arm" { cache.delete::<SlotUnit>(&key) } else { cache.delete::<UnionSurface>(&key) };
match writable {
true => assert!(removal.unwrap()),
false => {
let found = conflict(&removal.unwrap_err()).unread_slots.clone().unwrap();
// The reference names the unit arm's class, Denied2; here the variant.
let expected_type = match type_name.as_deref().unwrap() {
"Denied2" => "Denied",
other => other,
};
assert_eq!((found.type_name.as_str(), Some(found.slot_count), Some(found.declared_count), Some(found.occurrences)), (expected_type, slots, declared, occurrences));
assert!(cache.snapshot_envelopes().iter().any(|held| held.key == key), "a refused removal removed {key}");
}
}
walked += 1;
}
assert_eq!(walked, 3);
}
#[test]
fn the_consent_store_marks_are_unconsented_here() {
let rows: Vec<SlotRow> =
rmp_serde::from_slice(&vector("slot-outcomes.msgpack")).unwrap();
let dir = tempfile::tempdir().unwrap();
let mut found = 0;
for (store, key, _writable, _explained, _type_name, slots, declared, occurrences, _) in rows {
if store != "extra-slot-consent.cc" {
continue;
}
let (cache, _) = open_copy(&store, dir.path());
let held = cache.snapshot_envelopes().into_iter().find(|held| held.key == key).unwrap();
let (_, mark) = decode_payload_marked::<UnionSurface>(&held.payload).unwrap();
match slots {
// The reference's `consented` row is writable there (a listed compatible id covers it); here every mark refuses.
None => assert_eq!(mark, None, "{key}"),
Some(slots) => {
let mark = mark.unwrap_or_else(|| panic!("{key} carried no mark"));
// The first type read is the document, whose reference name is SurfaceDeclaring.
assert_eq!(mark.type_name, "UnionSurface", "{key}");
assert_eq!((Some(mark.slot_count), Some(mark.declared_count), Some(mark.occurrences)), (Some(slots), declared, occurrences), "{key}");
assert!(!cache.is_writable(UnionSurface::TYPE, &key).unwrap(), "{key}");
}
}
found += 1;
}
assert_eq!(found, 4);
}
#[test]
fn every_write_entry_refuses_a_marked_key_before_any_store_is_touched() {
let dir = tempfile::tempdir().unwrap();
let (mut cache, path) = open_copy("extra-slot-store.cc", dir.path());
let expected = mark("Composition", 3, 2, 1);
let before = std::fs::read(&path).unwrap();
let held = cache.snapshot_envelopes().into_iter().find(|held| held.key == "marked").unwrap();
let clean = cache.snapshot_envelopes().into_iter().find(|held| held.key == "clean").unwrap();
assert!(!cache.is_writable(UnionSurface::TYPE, "marked").unwrap());
assert!(cache.is_writable(UnionSurface::TYPE, "clean").unwrap());
let replacing = envelope("marked", rmp_serde::to_vec(&constructed()).unwrap());
assert_refused_with(&cache.put("marked", &constructed()).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.put_envelope::<UnionSurface>(replacing.clone()).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.put_raw_envelope(replacing.clone()).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.put_prepared_batch(vec![replacing]).unwrap_err(), "marked", &expected);
assert_refused_with(&cache.delete::<UnionSurface>("marked").unwrap_err(), "marked", &expected);
assert_refused_with(
&cache.update::<UnionSurface, _>("marked", |current| current).unwrap_err(),
"marked",
&expected,
);
// A batch with a marked key refuses whole: its clean member is not written either.
let mut rewritten = clean.clone();
rewritten.payload = rmp_serde::to_vec(&UnionSurface { version: 9, ..constructed() }).unwrap();
let batch = vec![rewritten, envelope("marked", rmp_serde::to_vec(&constructed()).unwrap())];
assert_refused_with(&cache.put_prepared_batch(batch).unwrap_err(), "marked", &expected);
assert_eq!(std::fs::read(&path).unwrap(), before, "a refused write touched the store");
assert_eq!(cache.snapshot_envelopes().into_iter().find(|entry| entry.key == "marked").unwrap(), held);
assert_eq!(cache.snapshot_envelopes().into_iter().find(|entry| entry.key == "clean").unwrap(), clean);
// A write to another key succeeds, and the marked record's bytes, schema id and stored-at are carried unchanged.
cache.put("clean", &UnionSurface { version: 9, ..constructed() }).unwrap();
cache.put("other", &constructed()).unwrap();
assert!(cache.delete::<UnionSurface>("other").unwrap());
let mut reopened = registered();
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
reopened.pull_all_backing_stores().unwrap();
let carried = reopened.snapshot_envelopes().into_iter().find(|entry| entry.key == "marked").unwrap();
assert_eq!(carried, held);
assert_eq!(carried.payload, vector("extra-slot-nested-record.msgpack"));
assert_eq!(reopened.get::<UnionSurface>("clean").unwrap().unwrap().version, 9);
}
#[test]
fn a_unit_arm_with_slots_decodes_as_the_unit_arm_and_marks() {
let (document, found) = decode_payload_marked::<SlotUnit>(&unit_arm_payload()).unwrap();
assert_eq!(document, SlotUnit { name: "unit-arm".into(), outcome: UnitOutcome::Denied });
assert_eq!(found, Some(mark("Denied", 1, 0, 1)));
let dir = tempfile::tempdir().unwrap();
let (mut cache, _) = open_copy("extra-slot-store.cc", dir.path());
assert_eq!(cache.get::<SlotUnit>("unit-arm").unwrap(), Some(document.clone()));
assert!(!cache.is_writable(SlotUnit::TYPE, "unit-arm").unwrap());
let error = cache.put("unit-arm", &document).unwrap_err();
assert_refused_with(&error, "unit-arm", &mark("Denied", 1, 0, 1));
assert_refused_with(&cache.delete::<SlotUnit>("unit-arm").unwrap_err(), "unit-arm", &mark("Denied", 1, 0, 1));
// Several elements are still one marked object, and a clean unit arm is unmarked.
let (_, found) = decode_payload_marked::<UnitOutcome>(&rmp_serde::to_vec(&(2u64, ("a", "b", "c"))).unwrap()).unwrap();
assert_eq!(found, Some(mark("Denied", 3, 0, 1)));
let (_, found) = decode_payload_marked::<UnitOutcome>(&vector("unit-arm-denied.msgpack")).unwrap();
assert_eq!(found, None);
}
fn unit_arm_payload() -> Vec<u8> {
let dir = tempfile::tempdir().unwrap();
let (cache, _) = open_copy("extra-slot-store.cc", dir.path());
cache.snapshot_envelopes().into_iter().find(|held| held.key == "unit-arm").unwrap().payload
}
#[test]
fn the_variant_row_of_the_slot_store_is_refused_whole_by_the_store_reader() {
// `marked-variant` is a five-slot record (a variant): this runtime holds no variants, and its v1 snapshot reader
// refuses such a record, so the store as written cannot load here and the other rows are walked without it.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("extra-slot-store.cc");
std::fs::write(&path, vector("extra-slot-store.cc")).unwrap();
let mut cache = registered();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
let error = cache.pull_all_backing_stores().unwrap_err();
assert!(format!("{error:#}").contains("is not decodable"), "{error:#}");
}
fn slot_unit_envelope(key: &str, payload: Vec<u8>) -> CultCacheEnvelope {
CultCacheEnvelope { r#type: SlotUnit::TYPE.into(), ..envelope(key, payload) }
}
#[test]
fn a_held_payload_the_type_cannot_read_is_read_only_and_refused_by_name() {
let mut cache = registered();
// A newer writer's record: an arm this build lacks and an extra root slot. get cannot read it, and it is the data
// read-only exists to protect.
let newer = rmp_serde::to_vec(&("CANARY-name", (99u64, ("x",)), "CANARY-extra")).unwrap();
cache.put_raw_envelope(slot_unit_envelope("n", newer)).unwrap();
assert!(cache.get::<SlotUnit>("n").is_err());
assert!(!cache.is_writable(SlotUnit::TYPE, "n").unwrap());
let replacement = SlotUnit { name: "n".into(), outcome: UnitOutcome::Denied };
let held = cache.snapshot_envelopes();
for error in [
cache.put("n", &replacement).unwrap_err(),
cache.put_raw_envelope(slot_unit_envelope("n", rmp_serde::to_vec(&replacement).unwrap())).unwrap_err(),
cache.delete::<SlotUnit>("n").unwrap_err(),
] {
let message = format!("{error:#}");
assert!(message.contains("\"n\"") && message.contains("tests.slot_unit") && message.contains("read-only"), "{message}");
assert!(!message.contains("CANARY"), "{message}");
}
assert_eq!(cache.snapshot_envelopes(), held);
}
#[test]
fn a_corrupt_held_payload_is_refused_naming_the_key_never_its_bytes() {
let mut cache = registered();
cache.put_raw_envelope(envelope("broken", rmp_serde::to_vec(&("CANARY-id", "CANARY-version")).unwrap())).unwrap();
assert!(!cache.is_writable(UnionSurface::TYPE, "broken").unwrap());
let error = cache.delete::<UnionSurface>("broken").unwrap_err();
let message = format!("{error:#}");
assert!(message.contains("\"broken\"") && message.contains("backup") && message.contains("store"), "{message}");
assert!(!message.contains("build that wrote it"), "{message}");
assert!(!message.contains("CANARY"), "{message}");
assert!(cache.is_writable("tests.unregistered", "broken").is_err());
}
fn held_marked_and_clean() -> (CultCache, tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let (cache, path) = open_copy("extra-slot-store.cc", dir.path());
(cache, dir, path)
}
#[test]
fn load_soa_does_not_replace_or_drop_a_marked_record() {
use cultcache_rs::CultCacheSoaTable;
let (mut cache, _dir, path) = held_marked_and_clean();
let all = cache.snapshot_envelopes();
let held = all.iter().find(|held| held.key == "marked").unwrap().clone();
let without: Vec<_> = all.iter().filter(|held| held.key != "marked").cloned().collect();
let mut replaced = all.clone();
replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = rmp_serde::to_vec(&constructed()).unwrap();
for table in [without, replaced] {
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(table)).unwrap_err();
assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1));
assert_eq!(cache.snapshot_envelopes(), all, "a refused load changed the image");
}
// The same record carried unchanged, with another key added, loads.
let mut kept = all.clone();
kept.push(envelope("added", rmp_serde::to_vec(&constructed()).unwrap()));
cache.load_soa(CultCacheSoaTable::from_envelopes(kept)).unwrap();
assert_eq!(cache.snapshot_envelopes().iter().find(|held| held.key == "marked"), Some(&held));
// A clean record may be dropped by a load.
let no_clean: Vec<_> = cache.snapshot_envelopes().into_iter().filter(|held| held.key != "clean").collect();
cache.load_soa(CultCacheSoaTable::from_envelopes(no_clean)).unwrap();
// The marked record is still on disk after the next batch.
let rewrite = envelope("added", rmp_serde::to_vec(&UnionSurface { version: 5, ..constructed() }).unwrap());
cache.put_prepared_batch(vec![rewrite]).unwrap();
let mut reopened = registered();
reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
reopened.pull_all_backing_stores().unwrap();
assert_eq!(reopened.snapshot_envelopes().iter().find(|held| held.key == "marked"), Some(&held));
}
#[test]
fn is_writable_agrees_with_the_writes_for_foreign_keys_and_other_types() {
let dir = tempfile::tempdir().unwrap();
// Only UnionSurface registered: the store's unit-arm record is foreign here.
let path = dir.path().join("extra-slot-store.cc");
std::fs::write(&path, without_variant_row(&vector("extra-slot-store.cc"))).unwrap();
let mut cache = CultCache::new();
cache.register_entry_type::<UnionSurface>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
assert!(cache.put("unit-arm", &constructed()).is_err());
assert!(!cache.is_writable(UnionSurface::TYPE, "unit-arm").unwrap());
assert!(cache.is_writable(UnionSurface::TYPE, "unheld").unwrap());
// Another registered type at a key a record of this one holds.
let (mut cache, _dir, _) = held_marked_and_clean();
let other = SlotUnit { name: "clean".into(), outcome: UnitOutcome::Denied };
assert!(cache.put("clean", &other).is_err());
assert!(!cache.is_writable(SlotUnit::TYPE, "clean").unwrap());
assert!(!cache.is_writable(SlotUnit::TYPE, "marked").unwrap());
assert!(cache.is_writable(UnionSurface::TYPE, "clean").unwrap());
}
#[test]
fn a_marked_key_refuses_a_batch_wherever_it_sits() {
let (mut cache, _dir, path) = held_marked_and_clean();
let before = std::fs::read(&path).unwrap();
let marked = envelope("marked", rmp_serde::to_vec(&constructed()).unwrap());
let clean = |key: &str| envelope(key, rmp_serde::to_vec(&constructed()).unwrap());
for batch in [
vec![marked.clone(), clean("a"), clean("b")],
vec![clean("a"), marked.clone(), clean("b")],
vec![clean("a"), clean("b"), marked.clone()],
] {
let error = cache.put_prepared_batch(batch).unwrap_err();
assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1));
assert_eq!(std::fs::read(&path).unwrap(), before);
assert!(cache.snapshot_envelopes().iter().all(|held| held.key != "a" && held.key != "b"));
}
}
#[test]
fn a_marked_decode_leaves_no_scope_open() {
assert_eq!(cultcache_rs::__enter_slots(), 0);
decode_payload_marked::<UnionSurface>(&vector("extra-slot-record.msgpack")).unwrap();
assert_eq!(cultcache_rs::__enter_slots(), 0, "a marked decode left its scope open on this thread");
let _ = decode_payload_marked::<UnionSurface>(&vector("depth-skipped-past-limit.msgpack"));
assert_eq!(cultcache_rs::__enter_slots(), 0);
}
#[test]
fn is_writable_is_false_for_a_type_with_no_home_store() {
let dir = tempfile::tempdir().unwrap();
let mut cache = registered();
// One store, routed to SlotUnit only: UnionSurface has no home.
cache.add_backing_store(SingleFileMessagePackBackingStore::new(dir.path().join("s.cc")), [SlotUnit::TYPE]).unwrap();
cache.pull_all_backing_stores().unwrap();
assert!(cache.put("k", &constructed()).is_err());
assert!(!cache.is_writable(UnionSurface::TYPE, "k").unwrap());
assert!(cache.is_writable(SlotUnit::TYPE, "k").unwrap());
}
#[test]
fn load_soa_refuses_a_same_length_replacement_of_a_marked_record() {
use cultcache_rs::CultCacheSoaTable;
let (mut cache, _dir, _) = held_marked_and_clean();
let all = cache.snapshot_envelopes();
let marked = all.iter().find(|held| held.key == "marked").unwrap();
// One value (a bin16) of the marked record's length: same length, different bytes. A value followed by padding
// would be refused at admission as bytes after the value, before the read-only check is reached.
let length = marked.payload.len();
let mut same_length = vec![0u8; length];
same_length[..3].copy_from_slice(&[0xc5, ((length - 3) >> 8) as u8, (length - 3) as u8]);
assert_eq!(same_length.len(), marked.payload.len());
let mut replaced = all.clone();
replaced.iter_mut().find(|held| held.key == "marked").unwrap().payload = same_length;
let error = cache.load_soa(CultCacheSoaTable::from_envelopes(replaced)).unwrap_err();
assert_refused_with(&error, "marked", &mark("Composition", 3, 2, 1));
assert_eq!(cache.snapshot_envelopes(), all);
}
// The payload check runs before the mark scope opens: a tail on a slot-bearing payload is refused by count, never marked.
#[test]
fn a_tail_on_a_slot_payload_is_refused_before_any_mark() {
let mut tailed = vector("extra-slot-record.msgpack");
tailed.push(0xc0);
let error = decode_payload_marked::<UnionSurface>(&tailed).unwrap_err();
assert!(format!("{error:#}").contains("payload has 1 bytes after its first MessagePack value"), "{error:#}");
// Nothing leaked: the next marked decode finds exactly its own mark.
let found = decode_payload_marked::<UnionSurface>(&vector("extra-slot-record.msgpack")).unwrap().1.unwrap();
assert_eq!(found.occurrences, 1);
}
// A store holding marked records and one with a tail is refused at load; the cache then refuses every write with the load
// refusal (not a per-key mark conflict), and the store file is untouched.
#[test]
fn a_store_with_marked_records_and_a_tail_is_refused_and_its_cache_writes_nothing() {
use cultcache_rs::CacheBackingStore;
use cultcache_rs::CultCacheLoadRefused;
let (mut held, _dir, path) = held_marked_and_clean();
let mut tailed = vector("union-surface.msgpack");
tailed.push(0xc0);
SingleFileMessagePackBackingStore::new(&path).push(&envelope("tailed", tailed)).unwrap();
let before = std::fs::read(&path).unwrap();
// The cache that held the store re-reads it and is refused.
assert!(format!("{:#}", held.pull_all_backing_stores().unwrap_err()).contains("bytes after"));
let fresh_value = constructed();
let mut fresh = registered();
fresh.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
assert!(fresh.pull_all_backing_stores().is_err());
for cache in [&mut held, &mut fresh] {
let staged = cache.prepare_entry("new", &fresh_value).unwrap().0;
let errors = [
cache.put("new", &fresh_value).unwrap_err(),
cache.put_prepared_batch(vec![staged.clone()]).unwrap_err(),
cache.put_raw_envelope(staged).unwrap_err(),
cache.delete::<UnionSurface>("new").unwrap_err(),
];
for error in errors {
assert!(error.downcast_ref::<CultCacheLoadRefused>().is_some(), "{error:#}");
}
}
assert_eq!(std::fs::read(&path).unwrap(), before);
}

View file

@ -1,58 +0,0 @@
//! `tests/vectors/stored-at-stamp`: the one shape a peer's `storedAt` must parse as. Every row is walked against
//! `is_stored_at_stamp`, and each row that is no stamp against the cache's two raw doors, `put_raw_envelope` and
//! `put_prepared_batch`: refused, nothing stored or minted, the store still writable after
//! (`variants:ruling:raw-put-without-stamp-refused`).
use std::path::PathBuf;
use cultcache_rs::{CacheBackingStore, CultCache, DatabaseEntry, SingleFileMessagePackBackingStore, is_stored_at_stamp};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.stamp_note", schema = "tests.stamp_note")]
struct Note {
#[cultcache(key = 0)]
text: String,
}
fn rows() -> Vec<(String, bool)> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/stored-at-stamp/stamps.msgpack");
rmp_serde::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
#[test]
fn the_stamp_check_agrees_with_every_vector_row() {
let rows = rows();
assert!(rows.len() > 30, "the vector was not read");
for (text, parses) in rows {
assert_eq!(is_stored_at_stamp(&text), parses, "{text:?}");
}
}
#[test]
fn a_raw_envelope_that_is_no_stamp_is_refused_and_nothing_is_stored() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("stamps.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Note>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
for (text, parses) in rows() {
if parses {
continue;
}
let (mut entry, _) = cache.prepare_entry("a", &Note { text: "peer".into() }).unwrap();
entry.stored_at = text.clone();
let error = cache.put_raw_envelope(entry.clone()).unwrap_err();
assert!(format!("{error:#}").contains("stored_at"), "{text:?}: {error:#}");
let error = cache.put_prepared_batch(vec![entry]).unwrap_err();
assert!(format!("{error:#}").contains("stored_at"), "{text:?}: {error:#}");
assert!(cache.get::<Note>("a").unwrap().is_none(), "{text:?} was stored");
assert!(!path.exists() || SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap().is_empty(), "{text:?} reached the file");
}
// Refused writes left the store writable: a stamped one lands under the stamp it carries.
let (mut entry, _) = cache.prepare_entry("b", &Note { text: "stamped".into() }).unwrap();
entry.stored_at = "2026-11-07T00:00:00Z".into();
cache.put_raw_envelope(entry).unwrap();
assert_eq!(cache.get::<Note>("b").unwrap().unwrap().text, "stamped");
assert!(cache.get::<Note>("a").unwrap().is_none());
}

Some files were not shown because too many files have changed in this diff Show more