Commit graph CultLib/packages
Author SHA1 Message Date
Meta Vens
7a0749b8e2 Say what the eleven stricter endpoint spellings actually are
Both comments around `STRICTER_THAN_CSHARP` called the divergences host
repairs. Most are not. Of the eleven, three carry a backslash and two have no
authority for `System.Uri` to accept; `refusedByCSharpUriShape` refuses those
five on the raw string, before there is a host to repair. Four are genuine host
repairs `URL` would make and C# would not — two trailing dots, two fullwidth
hosts — and the last two throw in `URL` before any rule of ours runs.

The same slip sits in `isProtectedEndpoint`'s own doc comment, over a different
eleven: it says `URL` repairs eleven spellings `System.Uri` refuses, when only
the four carrying a tab or CR/LF are rewritten and the other seven are admitted
as written.

Wording only; no rule changes. It matters because the next reader deciding
whether the pre-check earns its keep will reach for the reason the comment
gives, and "host repairs" points at the wrong half of the list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:21:42 +02:00
Meta Vens
9a4c9396b4 Stop the realtime decoder handing back a view of its own frame
Two mutants the cut-2 pass recorded as equivalent were not. Both notes were
reasoning, and both were wrong in the same way: they argued from the shape of
the code instead of trying the input that breaks it.

The decoder's length refusal reads `payloadLength < 0 || expected !== length`.
The note said the first clause could never fire on its own, because a negative
payload length makes the sum smaller than the frame. It does not: the sum also
carries the three identity lengths, and a negative payload cancels against
them. A 37-byte frame declaring `channelLength=65535, payloadLength=-65535`
sums to exactly 37, matches its own byte length, and without the clause decodes
to three blank identities and an empty payload where `CultMeshRealtimeWireProtocol`
refuses it. Two smaller spellings do the same, `ch=1, pl=-1` over 37 bytes and
`ch=2, pl=-1` over 38. No test covered that shape; one does now, and the
mutation is listed and killed rather than excused.

The payload copy was worse, because the fix and the bug looked identical. The
decoder used `bytes.slice(...)`, which copies a `Uint8Array` — and the existing
test passes one, so it was green — but Node's `Buffer` overrides `slice` with
an alias of `subarray`. A `Buffer` is what a socket read hands a Node caller,
which is exactly how the QUIC plane in cut 4 will reach this function, so every
decoded payload would have been a live view of a frame buffer the transport is
free to reuse.

`Uint8Array.prototype.slice.call(bytes, ...)` was the obvious repair and does
copy, but `%TypedArray%.prototype.slice` takes its result constructor from
`@@species`, and `Buffer`'s is `FastBuffer`: the payload then comes back a
`Buffer` from a `Buffer` frame and a `Uint8Array` from a `Uint8Array` one, so
the same frame compares equal or unequal under `deepStrictEqual` depending on
how it arrived. `new Uint8Array(bytes.subarray(...))` copies and is one type on
both. Three mutations now guard the line — return a view, return a `Buffer`
view, return a species-typed copy — and the first two die only on their own
input type.

Mutation runner: 75 mutations, 75 killed, 0 survivors, three green controls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:20:16 +02:00
Meta Vens
6cbc73fb17 Encode a CultMesh realtime frame in TypeScript
One codec, `packages/cultmesh-ts/src/realtime-wire.ts`, rule for rule what
`CultMeshRealtimeWireProtocol` does: the 37-byte little-endian fixed header
with magic 0x31545343, the delivery byte as the C# enum ordinal, the i64
epoch and sequence, three u16 identity lengths, the i32 payload length, the
stamped header size and wire version, then UTF-8 identities and the payload.
It touches no socket; QUIC stream framing stays transport-owned. The blank
reading in `Validate` is `IsNullOrWhiteSpace`, whose whitespace set is not
`String.prototype.trim`'s, so it borrows `isNullOrWhiteSpaceCSharp` from
`cultnet-ts` rather than growing a second reading of the same rule.

Byte parity is proven in both directions and in two processes, which is what
the authority vectors cost us to learn. `contracts/cultmesh/realtime-frame-vectors.json`
is written by the new C# test under CULTMESH_WRITE_VECTORS=1 and decoded and
re-encoded here; `realtime-frame-vectors.ts-written.json` is written by
`scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec, over
different identities and epochs, and decoded by the same C# test with the
reference implementation alone. Both files share one field shape, so each
side reads both with one reader. The two cases that would put megabytes into
a committed contract file, identities at exactly 65,535 UTF-8 bytes and a
64 MiB payload, are recorded as a build rule plus the digest of the whole
encoding; both sides build them the same way.

Three things the C# reference gets from its type system and TypeScript does
not, each refused rather than left to surface as a stray runtime error: a
`bigint` outside the i64 range, a delivery string the union cannot spell, and
a leading U+FEFF, which `TextDecoder` deletes by default and
`Encoding.UTF8.GetString` keeps. The last one is a real divergence the test
caught; the decoder now sets `ignoreBOM`. U+FEFF is not whitespace to .NET,
so such an identity passes the transport contract and reaches the wire.

There was no C# test for this codec before; this is also its first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 19:20:41 +02:00
Meta Vens
6c933d034b Spell the transcript separator as an escape, not a raw byte
The unit separator that joins protocol ids sat in the source as a literal
U+001F: once in `cultmesh-authority.ts` and twice in its test. Every editor,
every diff and every review renders it as nothing, so `join("")` reads as a
join on the empty string and the transcript looks wrong to anyone checking it
against the C# reference, which spells the same separator as an escape.
The bytes were right, which is exactly why nothing caught it.

The three become escapes. The bytes do not move: the C#-written and the
TypeScript-written route vectors both still verify through the shared module,
and the sorted-transcript test still builds the same expected bytes.

A new test reads both files and refuses any byte below 0x20 that is not LF or
CR, so the class recurs loudly rather than silently. It earned its keep on the
first run by catching a literal U+001F in the comment introducing it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:32:07 +02:00
Meta Vens
c64a2da81d Refuse the URL-repaired spellings in the protection gate too
`isLoopbackEndpoint` hands its endpoint to `refusedByCSharpUriShape` before
`URL` sees it; `isProtectedEndpoint` did not, and `URL` repairs eleven
spellings `System.Uri` refuses outright. Every one of them answered protected
here and unprotected in the C# reference, which is the dangerous direction:
the channel-protection gate opened on a route `IsProtected` would have closed.
`wss:x`, `wss:host`, `wss:host:8443`, `wss:/host` and `wss:///host` have no
authority for `System.Uri`; `wss://host\path` and `wss://host:8443\` carry a
backslash; `wss://ho\tst/m`, `wss://host\t/m` and `quic://ho\tst` a tab, and
`wss://host\r\n/m` a CR/LF, all of which `URL` deletes.

Soul's 71 raw-shape spellings go in as a table with the answer the C#
`IsProtected` gave for each on 2026-09-16, and the test asserts the invariant
rather than equality: TypeScript may never call an endpoint protected that C#
refuses. Eleven of the 71 now differ the safe way round, C# protected and this
not, and they are pinned by name rather than excluded, so neither a twelfth
joining them nor one of them flipping passes quietly. Two throw in `URL` before
any rule of ours runs (an IPv6 zone id, a non-breaking-space host); the other
nine are host repairs `refusedByCSharpUriShape` refuses on purpose, because the
same repair would make `isLoopbackEndpoint` lie. None is dialable as a browser
CultMesh route.

The mutation that drops the pre-check from `isProtectedEndpoint` alone, leaving
`isLoopbackEndpoint`'s in place, is killed by that test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 18:29:13 +02:00
Meta Vens
dfe582edbc Kill the browser's local reading of "unsigned", and mutate the browser too
Soul replaced the shared `isUnsignedCertificate` call in the browser's
session-acceptance check with a local empty-string comparison and every one of
the 18 browser tests still passed, because every test used an empty signature.
A whitespace signature is where the two readings part: the shared route
verifier accepts such a route as unsigned on a loopback local-development
endpoint, and an inline comparison then demands a provider proof for the
session the verifier just let through. The test now opens that session with
three spaces, U+0085 and a no-break-space/tab/newline mix as well as the empty
string, and asserts U+FEFF the other way, since it is whitespace to JavaScript
and a signature to C#.

The mutation runner grew a second target. `shared` is the one verifier, killed
by the cultnet-ts tests; `browser` is `packages/cultmesh-browser/src/index.ts`,
killed by the browser tests. A browser mutation cannot be killed by cultnet-ts
tests, so the killer is the target's own workspace, and each target carries its
own sidecar, control, sentinel and post-restore dist assertion.

Two repairs this pass earned:

- The sidecar is now removed inside the `finally`, right after the verified
  restore. A throw mid-run left it behind holding the pre-run bytes, and the
  next run "repaired" the file from them, silently reverting everything fixed
  in between. That happened here, twice, before it was noticed.
- The NUL in the loopback pre-check's strip regex had been written as a literal
  byte rather than an escape. It read the same, but git classified the file as
  binary and stopped normalising its line endings, so the whole file diffed as
  rewritten. Escaped, along with the ASCII host range beside it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:39:50 +02:00
Meta Vens
803ee12a7a Refuse the endpoint spellings WHATWG URL repairs and System.Uri does not
`isLoopbackEndpoint` handed the raw endpoint to `URL` and read the host it got
back. `URL` is a repairing parser and `System.Uri` is not, so seven spellings
were loopback here and remote in C#: a trailing dot on the host, which `URL`
drops; `ws:127.0.0.1`, `ws:/127.0.0.1` and `ws:///127.0.0.1`, whose authority
`URL` infers; a backslash path separator, which `URL` reads as a separator;
fullwidth digits and letters in the host, which `URL` folds to ASCII; and a tab
in the host, which `URL` deletes. Each of those is a local-development bypass:
a route the C# consumer would have demanded a certificate for opened unsigned
here.

`refusedByCSharpUriShape` refuses exactly those, on the raw string, before
`URL` sees it. Leading and trailing C0 controls and spaces around the whole
endpoint are stripped first, because both sides accept them. The spellings
Hands probed earlier and C# accepts -- `127.1`, hex, decimal,
`[::FFFF:7F00:1]`, userinfo, `localhost:8443` -- are untouched.

The doc comment claimed equivalence with `System.Uri.IsLoopback`. It is not
equivalent and now says so: two divergences remain, both unreachable from a
browser CultMesh route, and both named. An IPv6 zone id throws in `URL` and is
a host-local interface name no browser socket can dial; `file:` and `mailto:`
have no `URL` host and are refused by `openSocket`, which takes `ws:` and
`wss:` only.

The test is Soul's endpoint list with the C# answer as the expectation: 82 of
the 88 it probed, the six unreachable ones named in the doc comment instead.

Mutations: the trailing-dot refusal dropped, the scheme-and-authority shape
inverted, the backslash refusal dropped, and the non-ASCII host refusal
dropped -- each killed by the table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:22:06 +02:00
Meta Vens
c4c2330805 Clean the route fields before the transcript, as the C# constructors do
The C# reference signs a transcript over a *constructed* route, and the
constructors clean on the way in: `CultMeshAuthorityRoute.Clean` trims each
protocol id, drops the blank ones, keeps one of each ordinally and sorts
ordinally; `RequireNonEmpty` trims the runtime id and endpoint; `Require`
trims the provider key id and coordinates; `CultMeshRouteCertificate` trims
the Odin key id; a blank generation becomes the runtime id and endpoint
joined by "@". TypeScript cleaned none of it and transcribed the caller's
string, so a route C# signed and accepted was refused here the moment any
field carried padding or a duplicate.

Soul's tamper loop is the evidence: C# accepted a duplicated protocol id, a
padded generation and a padded Odin key id against the committed vector's own
signature, and TypeScript refused all three. The test now reruns those, plus a
padded protocol id, a blank one, a reversed list, a padded endpoint and a
padded provider key id, against the C#-written vector's signature. A padded
Odin key id must also find its root, so the trust map keys on the trimmed id
as the C# dictionary does.

`verseId` is left alone on purpose: it is not a route field, and
`CanonicalRoute` passes the caller's string into `Canonical` untrimmed. The
test pins that too.

`protocolId ?? "cultmesh.documents.v1"` is gone. A C# route with no protocol
ids is `Array.Empty<string>()` and transcribes as the empty string; reading
the handshake's `protocolId` as a one-element list wrote bytes the reference
never writes. `canonicalSession` matches its C# counterpart the other way:
it cleans nothing on the request, because the handshake message is what the
wire carried, and only the endpoint is cleaned, because the one C# passes is
`Route.Endpoint`.

Mutations: the dedupe removed, the blank-id filter removed, the generation
trim removed, the provider key id trim removed, and the Odin key id trim
removed from the lookup -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:14:35 +02:00
Meta Vens
e67e695a63 Read whitespace and the signature refusals as C# does
Two edge alignments that have to land together, because the test for the
first asserts the messages of the second.

`String.prototype.trim` is not `char.IsWhiteSpace`. JavaScript counts U+FEFF,
which .NET does not; .NET counts U+0085, which JavaScript does not. Every
`trim()` in the shared verifier stood in for a C# `IsNullOrWhiteSpace` or
`Trim()`, so both were wrong at those two code points: a U+0085 signature was
an unsigned route to C# and a bad signature to TypeScript, and a U+FEFF
signature the other way round. `isCSharpWhiteSpace`, `trimCSharp` and
`isNullOrWhiteSpaceCSharp` are that set, and `isUnsignedCertificate` and the
signature trim now use it. The test pins all 34 code points Soul probed on
both sides against the answers .NET 10 gave on 2026-09-16.

The C# `Verify` also has three signature refusals, not one: a signature that
is not base64, a decoded length that is not 64, and a signature that decodes
but does not verify. TypeScript collapsed the first two into the third and
lost the distinction the reference draws. Split, with each message pinned by
its exact C# string. `verifyP256` keeps its own checks: it is the silent
`VerifySession` path, which returns false for all three.

Mutations: the whitespace-set site reverted to `String.prototype.trim`,
U+0085 dropped from the set, and the two new messages merged back into
"invalid" -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:09:48 +02:00
Meta Vens
a801e0a441 Share one reading of an unsigned certificate between verifier and browser
The browser's local-development session-proof short-circuit tested
`!route.certificate`, while the shared verifier already treated an empty or
whitespace signature as unsigned, matching C# `Verify` (`:120`). A loopback
route carrying a blank-signature certificate therefore passed the verifier
and was then refused for lacking a provider key, where C# returns
`IsLocalDevelopment` and skips the session proof.

`isUnsignedCertificate` in `cultnet-ts/authority` is now the one owner of
that reading; the verifier and the browser both call it. A browser test pins
the local-development loopback open without a provider proof, the signed
loopback certificate still demanding it, and the remote counterpart refused
in both trust modes. The mutation runner gains two predicate mutations
(whitespace treated as signed; only a missing certificate treated as
unsigned), both killed by the existing whitespace-signature test in
cultnet-ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:43:09 +02:00
Meta Vens
1590145a81 Pin the TypeScript-signed direction of the authority parity check
`contracts/cultmesh/authority-route-vectors.json` proves C# bytes verify
in TypeScript. Nothing in the repo proved the reverse; Soul's probe did it
once in a scratch directory. It is now a committed check in the same shape,
mirrored:

- `scripts/sign-cultmesh-authority-vectors.mjs` builds the route and
  session transcripts with the shared module, signs them with fixed
  TypeScript-owned test keys over awkward inputs (a quic scheme with a
  query, three unsorted protocol ids, non-ASCII generation text, priority
  13), self-verifies, and writes
  `contracts/cultmesh/authority-route-vectors.ts-signed.json`.
- `CultMeshAuthorityProofTests.AuthorityRouteVectorsSignedByTypeScriptVerifyHere`
  reads that file with keys taken from the file, asserts the protocol ids
  are unsorted, that `Validate` and `VerifySessionProof` accept, and that
  a priority-tampered route and a replayed nonce are refused.
- The TypeScript vector test runs over both files, so a transcript change
  on the TypeScript side fails here before C# could trust a stale file.

Checked negatively: one base64 character changed in the committed
signature fails the C# test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:27:06 +02:00
Meta Vens
c2ffc32ff6 Make the TypeScript route verifier the C# rules exactly
verifyP256 handed WebCrypto `view.slice().buffer`. For a Node Buffer,
`slice` is `subarray`, so `.buffer` is the shared pool: a pooled Buffer or
a subarray of a larger allocation verified false while the same bytes as a
plain Uint8Array verified true. Copy exactly the viewed bytes.

Trust rules that had drifted from `CultMeshAuthorityProof.cs`:

- A certificate whose signature is empty or whitespace is an unsigned
  route (C# `Verify` line 120): local-development loopback accepts it,
  everything else refuses with the unsigned message.
- Loopback is `System.Uri.IsLoopback`: any 127.0.0.0/8 host, `::1`, the
  IPv4-mapped `::ffff:127.0.0.1` (only `.1`), and the host names
  `localhost` and `loopback`. WHATWG URL normalises `127.1`, `0x7f000001`
  and `2130706433` the way System.Uri does. Probed on the C# side through
  the public policy before the rule was written; the test pins that set.
- The root lookup runs before the validity window, so an expired route
  under an unknown root is refused as untrusted on both sides.
- A policy listing one Odin key id twice is refused at first use, the
  closest a plain object gets to the C# constructor throwing.

One test per rule, one mutation per rule in the runner's list. A fixture
whose protocol ids are unsorted and whose expected transcript is the
sorted one means removing the sort is now killed.

The runner now matches anchors in the file's own line ending. On an
autocrlf checkout the two multi-line anchors it already had ("route
transcript field order", "session transcript binds the nonce") matched
nothing, so the run threw at those entries instead of testing them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 16:24:41 +02:00
Meta Vens
66286b9dfb Delete the browser copy of the route verifier
cultmesh-browser now verifies routes and provider session proofs through
cultnet-ts/authority. Its own verifyAuthorityRoute, canonicalRoute,
canonicalSession, canonicalFields, verifyP256, isLoopbackEndpoint and the
two base64 helpers are gone; the six route and trust types are re-exported
under their old Browser names as aliases of the shared types. randomNonce
stays: it is client behaviour, not verification. All 17 browser tests pass
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
e234a64ae9 Add the shared CultMesh route verifier to cultnet-ts
Cut 1 of the TypeScript QUIC realtime plane: one TypeScript
implementation of Odin root lookup, the validity window, the canonical
route and session transcripts, P-256/P1363 verification, loopback
detection and the protected-scheme rule, published as
`cultnet-ts/authority`. Browser-safe by construction: WebCrypto,
TextEncoder, atob/btoa, URL, DataView; no node: import, and a test that
says so.

The protected-scheme rule is the C# rule (wss, https, any scheme
containing "quic") rather than wss-only, so the module is not wrong for
QUIC routes; the browser client still refuses non-ws(s) endpoints itself.

The transcript bytes are pinned by contracts/cultmesh/authority-route-
vectors.json, written by the C# reference under CULTMESH_WRITE_VECTORS=1
with fixed test keys and otherwise re-verified by both runtimes. The
vector carries a route certificate and a provider session proof.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 15:59:09 +02:00
Meta Vens
0f2c1f04ef Add PCG integer hashes to CultMath and release 0.2.3
CultMath's hash is the shader-style frac(sin(x) * 43758.5453) and
returns float. Unity.Mathematics' hash returned a well-mixed uint, and
seeding from the float hash collapses: a 100x100 position grid gave 1625
distinct values in Aetheria's zone generator.

- math.pcg(uint), pcg3d(int3/float2/float3), and pcg4d(int4/float4)
  follow Jarzynski and Olano, "Hash Functions for GPU Rendering"
  (JCGT 9(3), 2020). pcg3d and pcg4d are the paper's code from page 32.
  pcg is O'Neill's RXS-M-XS 32/32 output over one LCG step, with the
  constants checked against pcg-c's pcg_variants.h.
- CultMath has no uint vectors, so pcg3d and pcg4d carry uint bit
  patterns in int3/int4. The float overloads hash IEEE-754 bits via the
  HLSL intrinsic asuint(float), which is new. pcg3d(float2) holds z at
  0, the paper's route for 2 -> N inputs.
- The HLSL mirror gains the same functions, written over scalar uints
  so the text compiles both in dxc 1.9.0.5402 (lib_6_3, HV 2021, clean)
  and in C#. HlslSourceCompatibilityTests now compares int and uint
  arguments and results by value; a wrong shift in the mirror's pcg4d
  gives 208 mismatches.
- Known-answer vectors come from an independent Python transcription.
  pcg3d separates a 100x100 half-unit grid (the full int3). One
  component alone has one birthday collision there, as a 32-bit value
  can. The same grid on the integer lattice has none.
- design.md states that the hashes are integer-exact in HLSL and on the
  CPU: dxc wraps uint multiply and add modulo 2^32, as C# unchecked does.
- Bump to 0.2.3 and rebuild the tracked Unity DLL and pdb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:19:53 +02:00
Meta Vens
111b918ec0 Make CultMath normalize follow dxc and release 0.2.2
dxc 1.9.0.5402 lowers float2/3/4 normalize(x) to x * Rsqrt(Dot(x, x)),
and DXIL.rst defines Rsqrt as 1 / sqrt(src). A zero vector normalizes
to NaN and a NaN component poisons every component. CultMath divided
by max(length, 1e-20), which returned 0 for a zero vector and a huge
finite component beside a NaN one.

- math.normalize(float2/3/4) is now value * (1 / sqrt(dot(value, value))).
- The HLSL mirror drops cultmath_normalize and its epsilon constant: it
  now equals the normalize intrinsic, and nothing consumed it.
- HlslSemanticsTests pins zero and NaN normalize, the rsqrt bit pattern,
  and zero length/distance. design.md states the rule and notes that
  DXIL.rst's Rsqrt special-value table is a copy of Round's.
- GameCult.Geometry's iso-surface gave degenerate triangles (an inside
  vertex on the isovalue) a zero normal through the old epsilon. It now
  guards them explicitly, with a test.
- quaternion normalize keeps its identity fallback: quaternion is not
  an HLSL type.
- Bump to 0.2.2 and rebuild the tracked Unity DLL and pdb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:04:04 +02:00
Meta Vens
f0605367e0 Release CultMath 0.2.1
Bump the package and assembly version and rebuild the tracked Unity
DLL and pdb from a deterministic build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:16:32 +02:00
Meta Vens
7821e06d55 Add HLSL vector conversions, int clamp, and double frac to CultMath
Aetheria Cut 8a needs int2(float2), clamp(int, int, int), and
frac(double). Fill them in CultMath as dxc compiles them:

- generate-swizzles.ps1 emits same-size conversion constructors and
  math functions between float, int, and bool vectors. dxc lowers
  float to int as fptosi (toward zero; NaN and out-of-range undefined),
  numeric to bool as != 0 (fcmp une, NaN is true), bool to numeric as
  0 or 1. floatN(int) splat keeps floatN(0) from going ambiguous
  against floatN(intN).
- Int and int-vector clamp are min(max(x, a), b), matching dxc's
  IMin(IMax(x, a), b); inverted bounds return b.
- frac(double) keeps double precision. dxc demotes double frac to
  float, as it does exp and lerp; CultMath's existing double overloads
  already diverge that way, and design.md now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:16:32 +02:00
Meta Vens
546c919d4a Merge the CultCache store-routing cuts into main
Brings Cuts 1-6 of the Aetheria CultCache migration (one home store per
type, fixed routes, atomic commit with compare-exchange, parked SoA) and
CultMath 0.2.0 onto main, which had moved on with the CultNet contract
consolidation. The two lines touch disjoint files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 15:16:29 +02:00
Meta Vens
d4c43ed265 Say where intrinsic rules are pinned and that NaN does not propagate
The mirror compiles under using static CultMath.math, so it proves composition,
not intrinsic rules; HlslSemanticsTests alone pins those. design.md also states
where FMin/FMax/Saturate turn NaN or infinity into numbers and tells callers to
check isnan first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:47:10 +02:00
Meta Vens
19e23db211 Make double min/max follow DXIL FMin/FMax
dxc lowers double min/max to dx.op.binary.f64 opcodes 36/35 (FMin/FMax),
the same operations as float, so Math.Min/Math.Max (NaN-propagating) was the
wrong rule. Double min/max now use the float formulas, with NaN and signed-zero
assertions beside the float ones. Tracked Unity DLL rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:47:10 +02:00
Meta Vens
08e213dd7c Compare mirror results by bit pattern and say what that proves
SequenceEqual on floats treats -0 as 0. The mirror test now compares
SingleToInt32Bits, with any NaN equal to any NaN. Its summary and
design.md now say the test proves CultMath.hlsl's text computes the same
float32 results as C# math on the CPU, not that a GPU agrees: driver sin
precision alone makes cultmath_hash and value noise differ on hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:32:43 +02:00
Meta Vens
9407da0461 Match float min, max, clamp, and saturate to DXIL FMin/FMax/Saturate
dxc lowers float min/max/clamp to dx.op FMin/FMax (36/35) and saturate to
dx.op Saturate (7). DXIL.rst defines them: FMin(a, b) is a < b ? a : b,
FMax(a, b) is a >= b ? a : b, a NaN operand returns the other, and
Saturate is FMin(1, FMax(0, x)). C# used MathF.Min/Max, which pass NaN
through and order signed zeros, so saturate(NaN) was NaN instead of 0.

Every float overload now follows the spec, normalize uses the same max as
its mirror, and the mirror's saturate spells Saturate's operand order
(which maps -0 to +0, where clamp(x, 0, 1) keeps -0). Non-NaN results
change only in the sign of a zero. design.md records the rules and that
dxc marks float math fast, so GPU NaN results are not guaranteed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:32:19 +02:00
Meta Vens
d298d666d0 Rebuild the Unity CultMath DLL without Source Link, non-incrementally
The previous commit's DLL and pdb came from an incremental build that did
not recompile after the Source Link switch, so the pdb still carried the
commit URL. The script now builds with --no-incremental, so a stale obj
directory cannot decide what the byte check compares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:28:44 +02:00
Meta Vens
a9a2ba69d2 Build the Unity CultMath DLL independent of the commit
The tracked DLL is committed with its source, so a build that embeds the
commit can never match it. Two places named the commit: the informational
version (0.2.0+<sha>) and Source Link's commit URL in the pdb, whose content
id the DLL also carries. The csproj drops the revision from the version, and
the Unity build turns Source Link off; NuGet packs keep it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:27:44 +02:00
Meta Vens
6bc6db68d4 Document where matrix row writes are silently lost
The operator kept the [UnscopedRef] ref this[int] row indexer as is. design.md
and the float2x2/float3x3 indexers now say where m[r][c] = v lands: locals,
array elements, class fields, and ref locals keep the write; a readonly field,
in parameter, property (auto-properties too), or static readonly identity
takes a defensive copy, so the write compiles and is lost. Fix: copy to a
local, write, assign back. Verified by compiling and running each case with
Roslyn against CultMath (no diagnostics; writes land 5, lost 0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:18:00 +02:00
Meta Vens
77ee6fe488 Pin dxc to dxc_2026_07_29 and verify its SHA256
get-dxc.ps1 fetched releases/latest with no pin or hash, so the compiler that
defines parity could change under the tests. It now downloads v1.9.2607's
dxc_2026_07_29.zip (dxcompiler 1.9.0.5402) and refuses a zip whose SHA256 is
not A1DFB116...CD241. The hash was taken from the zip downloaded from
Microsoft's GitHub release and matches the digest GitHub publishes for the
asset. Script ran end to end and installed 1.9.0.5402.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:17:11 +02:00
Meta Vens
2940ca0944 Compare every HLSL mirror function against C# math, bit for bit
Only cultmath_snoise was compared, at four inputs, so a flipped cultmath_step,
a changed hash constant, and a snoise2 tie change all passed. The mirror is
now discovered by reflection: every cultmath_* overload with a math
counterpart (26 functions) runs over 104 deterministic cases - each special
value in every argument (0, -0, +-1, ties, 1e-7, +-1e4, 1e7, NaN), special
pairs, seeded randoms, and vectors with x == y (snoise's x0.x == x0.y) - and
must match exactly. Both sides are float32 C# over the same intrinsics, so no
tolerance. A cultmath_* function with no counterpart must be named in
MirrorOnly. The four-input snoise theory is gone.

Mutations: flipped step args fail (349 mismatches); hash constants changed
by more than one float32 ulp fail (43758.55: 467, 127.2: 289, 74.8: 97).
43758.5453 -> .5454 is the same float32 and cannot fail. snoise2 x0.x > x0.y
-> >= is invisible: over 1,829,415 exact ties the result is bit-identical,
because the off-simplex corner's kernel is <= 0 on the shared edge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:17:11 +02:00
Meta Vens
e0a054a7b4 Match step's NaN result to what dxc emits
dxc 1.9.0.5402 (dxc_2026_07_29, lib_6_3, -HV 2021, -Od) lowers step(e, x) to
fcmp olt x, e; select 0 : 1, so NaN on either side gives 1. The previous
x >= e ? 1 : 0 rule came from prose, not the compiler. math.step and
cultmath_step now use x < e ? 0 : 1 for every overload; the mirror's
cultmath_step compiles to the same fcmp/select as the intrinsic. Unity shader
copy synced; DLL rebuilt and byte-checked by build-unity-package.ps1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:09:49 +02:00
Meta Vens
10b2c15fb8 Rebuild the CultMath 0.2.0 Unity DLL with the generated surface
Fresh deterministic netstandard2.1 build from the CRLF working copy a checkout
produces; build-unity-package.ps1 byte check passes. The DLL grows from
66,048 to 297,472 bytes, almost all generated swizzles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
7a1bd1ebb1 Document the full HLSL target and its source transformations
design.md states the generated swizzle and mixed-constructor surface, writable
matrix rows, HLSL intrinsic types and NaN behavior, the deferred matrix items
(float4x4, transpose, determinant, _11 names), the exact HLSL-to-C#
transformations the compatibility test applies, and the dxc check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
88eb365c05 Check bridge declarations, not file names; strip manifest BOMs
build-unity-package.ps1 read the bridge's file names to decide whether it
re-declared core CultMath sources. It now parses declarations: core type
names come from src/CultMath (comments stripped), and every bridge source may
declare types only in CultMath.UnityBridge and never a core type name.
Probed by injecting "struct float3" into UnityConversions.cs: the script
throws.

Strips the UTF-8 BOMs this branch added to both Unity package.json files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
360176a7da Compile the real HLSL mirror as C# and with dxc; fix its errors
HlslSourceCompatibilityTests now reads shaders/CultMath.hlsl, applies only
the documented transformations (drop include guards, drop Texture2D/
SamplerState functions, file-scope static const -> const, f literal
suffixes, wrap in a class), and compiles it with Roslyn against CultMath.
Any compile error fails the test (probed with an undeclared call). The
compiled cultmath_snoise(float3/float2) also matches math.snoise.

dxc -T lib_6_3 -HV 2021 rejected the mirror: cultmath_snoise(float2) called
the undeclared cultmath_simplex_mod289/permute. Renamed to the declared
cultmath_snoise_* helpers. Vector const locals became plain locals (C# const
is primitives only), and cultmath_step uses value >= edge like HLSL step and
the C# mirror. dxc 1.9.0.5402 now compiles the file cleanly. The Unity
package's Shaders copy is synced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:03 +02:00
Meta Vens
5d8ef0c3de Generate every HLSL swizzle and mixed constructor; fix intrinsic types
tools/generate-swizzles.ps1 writes the checked-in src/CultMath/Swizzles.g.cs
as partial structs plus a partial math: for float2/3/4, int2/3/4 and bool2/3/4
every swizzle of length 2-4 including repeats, in xyzw and rgba naming, with
setters only on non-repeating masks, plus single-component r/g/b/a. double2/3
get lengths 2-3 (there is no double4). Every HLSL mixed constructor
(float4(float2, float2), float4(float, float2, float), copy forms, ...) exists
as a struct constructor and, for float/int/bool, a math function. The
hand-written swizzles and mixed constructors it supersedes are deleted.

Matrices store private rows and return ref float2/float3 from the row
indexer ([UnscopedRef], polyfilled for netstandard2.1), so m[1][2] = s
compiles as in HLSL; _mRC are get/set properties over the rows. Equality and
hashing stay value-based.

Intrinsics follow HLSL: sign returns int/intN and yields 0 for NaN; step is
x >= y ? 1 : 0 so NaN yields 0; any/all accept float and int vectors;
min/max/abs on int vectors return int vectors, and abs(int) returns int.

Strips the UTF-8 BOMs this branch added to math.cs, CultMath.csproj and
HlslSemanticsTests.cs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:57:02 +02:00
Meta Vens
2ad8dbc9b3 Rebuild the CultMath 0.2.0 DLL from normalized sources
The previous prebuilt DLL/PDB came from LF working-copy files. The PDB embeds
source checksums, so a text=auto checkout (CRLF on Windows) built different
bytes and build-unity-package.ps1 would reject the tracked artifact. Rebuilt
from a clean checkout of the committed sources and re-verified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:30:49 +02:00
Meta Vens
d9e469554d State HLSL source compatibility as CultMath's design target
The design doc now names HLSL as the parity target: HLSL-shaped math
compiles as C# under using static CultMath.math, components and swizzles are
writable, comparisons are component-wise, matrices are row-major through mul,
and select uses HLSL's argument order. "Prefer small immutable value types"
becomes plain mutable value types. CultMath keeps its own decisions where
HLSL is silent (safe normalize, float hash, its own Random), and the core
assembly stays engine-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:30:00 +02:00
Meta Vens
23cc86c4e9 Ship CultMath 0.2.0 to Unity with a UnityEngine conversion bridge
- Bump GameCult.Math and org.gamecult.cultmath to 0.2.0; the CultCache
  Unity tools package now depends on cultmath 0.2.0.
- Rebuild the tracked netstandard2.1 CultMath.dll/pdb with the builder's
  deterministic command; build-unity-package.ps1 verifies it byte-for-byte.
- Add Runtime/UnityBridge (CultMath.UnityBridge asmdef) with ToCultMath /
  ToUnity / ToColor extension methods for Vector2/3/4, Vector2Int/3Int,
  Quaternion and Color. C# only admits conversion operators on the source
  or target type, and the core DLL stays engine-free.
- The builder admits exactly that one bridge assembly: it must reference
  UnityEngine and CultMath.dll, and no core source may be compiled in the
  package.
- Sync the Unity copy of CultMath.hlsl and the third-party notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:30:00 +02:00
Meta Vens
d514efebba Make CultMath HLSL-shaped: writable components, bool-vector comparisons
HLSL is now CultMath's semantic authority where it defines one.

- float2/3/4, int2, double2/3, bool2 and quaternion are plain structs with
  public mutable fields instead of record structs. Indexers and swizzles
  (float2.yx; float3 xy xz yx yz zy xyz xzy yzx zyx; float4 xy xyz xyw yzx)
  have setters.
- ==, !=, <, >, <=, >= are component-wise and return bool2/3/4. Equals and
  GetHashCode stay value-based so vectors remain dictionary keys.
  quaternion is not an HLSL type and keeps a bool ==.
- Scalar/vector arithmetic and comparisons get explicit overloads on both
  sides instead of leaning on implicit splat.
- New types: bool3, bool4, int3, int4, float2x2, float3x3 (row-major like
  HLSL, _mRC fields, Rotate / RotateX/Y/Z / Euler with math.RotationOrder).
- New intrinsics: mul (matrix-vector both sides, matrix-matrix), any, all,
  select (HLSL argument order), log, vector pow/sqrt/sin/cos/acos/atan2,
  double min/max, int3/int4/bool3/bool4/float2x2/float3x3 constructor
  functions.
- simplex_noise is renamed snoise and gains the Ashima 3D variant; the HLSL
  mirror follows as cultmath_snoise(float2/float3).
- Random gains NextFloat3() and NextFloat3(float3 max) on the same draw order.

Tests pin snoise(float3), float3x3.Euler and float2x2.Rotate to
Unity.Mathematics reference values, and an HLSL-shaped source block compiles
under using static CultMath.math.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:29:59 +02:00
Meta Vens
47c7111897 Delete the TypeScript re-entry guard; state the store-wait rule once
The AsyncLocalStorage guard refused a store's change handler or timer
writing back to its cache without the store waiting for it, which only
queues, and still missed a wait that escapes the async context. The serial
queue and its own-promise chain stay.

The storePushThatAwaitsItsOwnCacheIsRefusedInsteadOfHanging test is
replaced by storeChangeHandlerWritingBackQueuesAfterTheOperation: a store
push emits an event whose handler puts without being awaited; both writes
land in order and store and cache agree.

The contract's Locking section states the rule for all runtimes: a store
call must not wait for a write to its own cache; unawaited write-backs
queue. Python refuses a same-thread re-entrant mutation; no runtime detects
a wait on another thread or async context. The globals row states the
two-step legacy retirement with compensation, and that a crash or double
failure leaving both records is refused at the next pull.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:43:46 +02:00
Meta Vens
9c3c92634a Require an atomic push_all from every Rust backing store
The default CacheBackingStore::push_all deleted every row and then pushed
one by one, so a failing push left the store emptied or partial while
put_prepared_batch claimed all-or-nothing. The default is deleted:
push_all is a required method documented as one atomic replace, and a
compile_fail doctest pins that a store without it does not compile.
Every in-tree implementor (single-file, both redb stores, the test store,
Epiphany's runtime store) already supplies its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:28:07 +02:00
Meta Vens
4158a38a34 Refuse re-entrant TS cache calls and adopt legacy-key globals on load
The serial chain now advances on its own promise and returns the
operation's promise untouched, so an unawaited failing put is an
unhandled rejection again instead of being swallowed by the chain's
catch.

A queued call started from inside a store call, decoder, accessor or
updater of the same cache's running operation throws at entry instead of
waiting on itself. Detection is an AsyncLocalStorage list of running
operations, so independent concurrent callers are still queued.

A pull adopts a global persisted under a key other than __global__ as
that type's global without writing. The first write or delete of it
removes the legacy record in the same call; a failed removal takes the
new record back out. Two globals of one type on disk, under any keys,
are still refused.

concurrentGlobalPutsCannotBothReachDisk now pauses inside push and fails
when the chain is replaced by Promise.resolve().then(operation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:28:07 +02:00
Meta Vens
5be87c7696 Refuse re-entrant Python cache mutations and adopt legacy-key globals on load
Attach, pull, registration, writes and deletes raise when started on a
thread already inside a mutation of the same cache (a store call,
decoder, encoder, extractor or updater), instead of running inside it
and corrupting the state being built. Reads stay reentrant. Internal
delegation (put_global, update, delete_global, register_registry,
add_backing_store) goes through private helpers so it is not re-entry.

A pull adopts a global persisted under a key other than __global__ as
that type's global without writing. The first write or delete of it
removes the legacy record in the same call; a failed removal takes the
new record back out. Two globals of one type on disk are still refused.

The lock and unlocked-read comments now state the per-call guarantee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:27:55 +02:00
Meta Vens
15c6a5d513 Serialize the Python cache behind one reentrant lock per instance
The Python cache took no lock, so "refused write changes neither store nor
cache" and the one-home rule held only for one caller at a time. A put paused
between its store push and its apply let a concurrent attach route the type to
a new home (the record stayed in the old store), and let a second global put
reach disk first so cache and disk disagreed.

CultCache now holds a threading.RLock around attach, pull, registration, every
write and delete (globals and update included), name/index resolution and
snapshots, matching the C# per-cache gate and the TS serial queue. Single-dict
reads stay unlocked. No public API change.

Tests: test_concurrent_put_and_attach_cannot_land_record_in_non_home_store and
test_concurrent_global_puts_cannot_both_reach_disk force the interleaving with
a store that pauses inside push; both fail on 4dc7f3a and pass here. The
contract's Python cells drop the one-caller caveat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:03:45 +02:00
Meta Vens
4ed9871252 Make Rust CultCache batch and SoA loads validate before admitting
put_prepared_batch refuses an empty key or stored_at before any store is
touched. Its one-home check covers the staged records only, and the home store
receives only its own records (those this cache holds for it plus the staged
ones), so a single-home batch is no longer refused because other homes hold
records.

load_soa builds the next view in a local map, refusing an unregistered type or,
once a store is attached, a type with no home store; the view is replaced in one
step, so a refused load leaves the prior view intact.

load_envelope is deleted: it had no callers and admitted records into the view
without a store.

Tests: refuse-at-load and pull start from a non-empty cache and assert prior
state intact; atomic load_soa; single-home batch beside other homes' records;
batch with empty key or stored_at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:57:44 +02:00
Meta Vens
286435d861 Serialize TypeScript CultCache mutations and validate writes whole
A per-cache promise chain now runs addBackingStore, pullAllBackingStores,
registration and every write and delete one at a time, so validation, the
store call and applying to the cache cannot interleave with another mutation.
Those methods return promises; the builder configures its unreleased cache
synchronously. Reads stay synchronous.

put and putEnvelope resolve the home, check the global key and run the name and
index accessors before the store is touched; applying afterwards has no
fallible step. Each held record keeps the lookup values it produced at
admission, so overwrite and delete run no accessor. A global is only stored
under __global__: any other key is refused, and replacing the global writes
__global__ with no delete-then-push. registerDocumentType, registerNameLookup
and registerIndex check schema-id and schema-name conflicts and derive every
lookup before installing anything.

cultmesh-ts awaits registerDocumentType in createNode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:57:01 +02:00
Meta Vens
9288f93c08 Validate a Python CultCache write whole before touching a store
put, put_envelope and put_envelopes now compute the new records' name and
index keys and resolve the home store before any push; applying to the cache
afterwards runs no extractor. Each held record keeps the lookup keys it
produced at admission, so overwrite and delete remove lookups without calling
user extractors. put_envelope and put_envelopes refuse an empty key and a
global under any key but __global__. Per-type dicts are created only after the
store accepts. register_document_type, register_name_lookup and register_index
derive every lookup under the candidate extractors first and install
extractors and lookups together, so a raising extractor installs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:48:59 +02:00
Meta Vens
446122daef Build the whole next state before a pull swaps it in
A pull is all-or-nothing, but TS cleared the cache and then ran user name and
index accessors while re-applying entries, and Python swapped values in and
then rebuilt lookups. An accessor that threw left the cache partly loaded.

TS: the hydrated maps (entries, type entry ids, name and index lookups, global
keys) are one HydratedState. The pull builds a fresh one, running every check
and accessor, and replaces #hydrated in one assignment at the end. The
reset-then-apply sequence, #resetHydratedState, the staged global-key map and
the three per-map getOrCreate helpers are deleted; apply/remove take the
target state. The duplicate-global check now lives only in apply. Name and
index lookup rebuilds build a local map before assigning.

Python: the pull derives names and indexes into local dicts and assigns
values, envelopes, names and indexes together; _rebuild_indexes delegates to
the same pure _derive_lookups.

Rust already stages into a local map and assigns self.entries last, with no
derived lookups; unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:03:30 +02:00
Meta Vens
1f442f3345 Refuse duplicate global records while staging a pull
A load is all-or-nothing. TypeScript checked for a second record of a
global type inside #applyHydratedEntry, which runs after
#resetHydratedState, so a refused pull left the cache cleared and partly
reloaded. The check now runs in the staging pass beside the home check,
before anything is cleared; the apply step's pull-only branch is gone.

Python already validated before swapping, but its check only fired when
the __global__ record arrived second, so a store holding __global__ then
another key of a global type loaded both. Any second record of a global
type is now refused regardless of order.

Rust has no global document types and replaces its entry map only after
staging; it had neither flaw.

pullWithDuplicateGlobalLeavesCacheUnchanged (TS) and
test_pull_with_duplicate_global_leaves_cache_unchanged (Python) fail on
9ab1706 and pass here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:57:09 +02:00
Meta Vens
a23a33e184 Keep cultcache-py record homes stable and refuse strays at load
Conform cultcache-py to the store-composition contract, following C#:

- add_backing_store and add_generic_store raise, attaching nothing, when
  the new store would move the home of any document type the cache
  already holds.
- add_backing_store(store, []) attaches the generic store (same as
  TypeScript, Rust and C#'s untyped attach) instead of silently doing
  nothing.
- pull_all_backing_stores stages every store's records and replaces the
  cache's view only when all loaded. A record delivered by a store that is
  not its home raises naming the key and both stores; nothing is admitted
  and no store is written.

The zero-store in-memory test passes on 6bb41f3 as well (Python already
conformed); the other new tests fail there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:50:48 +02:00
Meta Vens
03ff299233 Keep cultcache-rs record homes stable and refuse strays at load
Conform cultcache-rs to the store-composition contract, following C#:

- add_backing_store returns Err, attaching nothing, when the new store
  would move the home of any entry type the cache already holds.
- pull_all_backing_stores loads every store into a staged image and
  replaces the cache's entries only when all loaded. An entry delivered by
  a store that is not its home returns Err naming the key and both
  stores; nothing is admitted and no store is written.
- A cache with zero stores is in memory: put, put_envelope,
  put_raw_envelope, put_prepared_batch and delete no longer error. Once
  any store is attached, a type with no home still errors.

Tests for each rule fail on 6bb41f3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:50:48 +02:00