Commit graph CultLib/tests
Author SHA1 Message Date
Meta Vens
8cf89a7e56 Extend the Cut 5 interop lane: a late C# native connector gets the TS retained frame
Cut C's headline cross-process proof. The TS `serve` peer already tore its
listener down the instant it finished broadcasting, so nothing could dial it
late; it gains `--linger-ms` to hold the listener open afterward instead. The
interop harness starts that provider, runs a throwaway `dial` peer that
receives every frame and disconnects (the provider requires a first peer
before it broadcasts at all, so this is also what proves broadcasting
finished), then only after that dials with the C# native connector and
asserts the frame it gets is the retained one, by payload, not just
non-empty. New `NativeConnectorReceivesTheRetainedFrameWhenJoiningLate` in
GameCult.Mesh.Quic.Native.Tests carries the assertion; the existing
`NativeConnectorReceivesFromExternalProvider` is untouched. Windows-only,
matching the native connector under test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 18:32:39 +02:00
Meta Vens
7b7de5b071 Fix a flaky disconnect wait in the reliable-ordered retention test
AReliableOrderedFrameIsNeverRetainedAndIsNotDeliveredToALateJoiner disposed
the first session and then waited on server.ConnectionCount == 0 before
attaching a second one, which timed out under Yggdrasil's ygg-verify
container (dotnet test, cultlib-quic-native-dev image): the retention rule
under test does not depend on the first peer disconnecting, so the fix keeps
both sessions connected concurrently and drops the flaky wait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 18:20:36 +02:00
Meta Vens
680af452c6 C# QUIC server: retain the newest latest-only frame per (channel, body)
CultMeshQuicRealtimeServer mirrors the TS provider: a `_retained` dictionary
holds the newest latest-only frame per (channel, body) key, updated in
BroadcastAsync with the same CultMeshGeneration comparison the receive-side
filter already uses. AcceptLoopAsync seeds a newly accepted client with the
retained frames. Both the retained update+fan-out and the client
registration+seed run under one lock (`_retainedGate`), so a seed and a
concurrent broadcast are totally ordered and a client can never end up seeded
with a frame older than one it already received via broadcast. Reliable-
ordered frames are events and are never retained.

CultMeshGeneration is now internal so the server can reuse the transport's
own generation comparison instead of a second implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 18:17:28 +02:00
Meta Vens
92f2566d2c Fix the golden-bytes lane to match by sequence, not assume frame 1
Found by a real failure on the Linux verification container: latest-only
coalesces on both the provider's outbox and the managed connector's own
receive-side inbox, so a single ReceiveAsync() call is not guaranteed to
land on the first frame broadcast — on a slower host it landed on sequence 3.
The C# test now also prints CULTMESH_GOLDEN_SEQUENCE, and the harness looks
up the TypeScript peer's logged encoding of that same sequence instead of
hardcoding sent === 1.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:33:02 +02:00
Meta Vens
6c7cfa7a94 Studio: refuse built-ins by asset identity, not GUID digits; add Clear for stale keys
TrimEnd('0','f') missed Library/unity default resources built-ins whose GUID
has no all-zero or all-f suffix (e.g. 0000000000000000e000000000000000, the
LegacyRuntime font / Cube mesh in 6000.3.24f1). Ask AssetDatabase instead: a
real project asset is always a main or sub-asset under Assets/ or Packages/;
built-ins are neither. Also add a Clear button beside the stale-value warning
so a non-resolving stored key can be cleared without picking a substitute
asset first (drawing still never rewrites a stored value on its own).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 20:09:53 +02:00
Meta Vens
1e398e0116 Studio: read and write sub-asset Addressables keys, warn on stale values
Ruling S (2026-09-17): a stored [CultInspectorAssetGuid] value is the
Addressables key form itself, the bare GUID for a main asset or
"guid[subAssetName]" for a picked sub-asset (one sprite of a sheet, say) -
the same shape AssetReference keeps and Addressables.LoadAssetAsync<T>
accepts.

CultAssetGuidKey (engine-free, GameCult.Caching) pins the parse/format rule
with unit tests (bare guid, guid[name], names with brackets/spaces,
malformed input), so headless checks can share it without touching Unity.

The Studio drawer's AssetGuid branch now: resolves either key form on read;
refuses to store a built-in resource's GUID (it has no Addressables entry)
and logs why instead of silently writing nothing; shows a warning next to a
stored value that fails to parse or resolve instead of drawing it as an
ordinary empty field, and never rewrites that stale value on its own.

Rebuilt GameCult.Caching/GameCult.Caching.MessagePack plugin DLLs
(scripts/build-unity-package.ps1 -UpdateTemplate) since the new type ships
in GameCult.Caching. Compiled CultCacheStudioDrawers.cs and
CultCacheStudioWindow.cs against Unity 6000.3.24f1's editor reference set
plus these plugins in a scratch csproj (removed after): 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:48:45 +02:00
Meta Vens
95a3c32250 Replace the asset path attribute with a stable GUID identity
CultInspectorAssetPathAttribute stored an engine asset path, which breaks
when the asset moves. CultInspectorAssetGuidAttribute stores the asset's
stable identity (in Unity, the .meta GUID) instead. The Studio drawer now
resolves the stored GUID to a path only to load the preview object, and
writes back AssetPathToGUID on a change. No path form survives for the
next field to pick up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 18:43:03 +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
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
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
29bec57021 Decode an edit's Record from the bytes its Document came from
BeginEdit cloned Document eagerly but Record lazily from record.Document,
so a cache change in place before Record's first read made the two copies
come from different states. BeginEdit now serializes the document once and
deserializes Document from those bytes; Record deserializes the same bytes
on first read. Record is the state the edit began from, and costs one
deserialize only when a drawer reads it, with no second serialize.

Each commit still spends the edit, so a slider drag begins an edit per
frame; that serialize was already paid for Document, and Record adds a
deserialize only in frames where a drawer reads it.

The new test mutates the cached document between BeginEdit and the first
Record read; it fails on the lazy clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
a92381e589 Write an unset CultRecordRef as "" in every position
452f928 made nil the unset wire form, including dictionary keys: a
Dictionary<CultRecordRef<H>, float> holding the empty key encoded it as C0.
Python msgpack refuses nil map keys under strict_map_key, its default, so
cultcache-py cannot decode such a record. Nil cannot be the one canonical
form; "" can, and every runtime reads it.

The writer always writes "". The reader still takes nil as unset, because
452f928/b85a828 stores and Aetheria's Cut 9 import wrote nil; such a record
converts to "" on its next save and is byte-stable after that. The
store-composition contract names "" for value and key position, requires
readers to accept nil, and tells other runtimes to write "".

The tests pin payload bytes, so each claim is consequential: an unset ref
writes "", reloads through a fresh cache and resaves byte-identical; a
nil-written legacy payload reads as unset and resaves as ""; a ref-keyed
dictionary with an empty key writes "", reads back unset and resaves
byte-identical. The nil writer fails every byte assertion, a reader taking
nil as a set key fails the legacy pair, and a reader taking "" as a set key
fails the read-back assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:34:23 +02:00
Meta Vens
90e7dbee27 Give inspector drawers the record they sit in, read-only
A drawer saw only its value; Aetheria's schematic-shape drawer needs sibling
members of the owning document (texture path, aspect, hull tints).

The model owns the seam: CultInspectorEdit.Record is a second copy of the
stored document as the edit began, cloned on first read and kept for the
edit. The Studio lowers it as CultInspector.Record, so a runtime CultUI
panel gets the same seam from the same edit. The Draw signature is
unchanged, so existing drawers keep compiling.

Why not hand drawers the edit copy the Studio already draws: that copy is
what Commit upserts, so a write through it would be saved without passing
through the returned value, GUI.changed, or read-only metadata. The record
copy is read by nothing, so writing to it saves nothing and changes neither
the edit nor the cache. It does not show edits other drawers made earlier in
the same frame.

The Studio's separate document-type field collapses into the held edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:01:22 +02:00
Meta Vens
452f928bb7 Write an unset CultRecordRef as nil and read it back unset
The formatter wrote default(CultRecordRef<T>) as nil but read nil back as
"", so re-upserting a loaded record changed its bytes (0xC0 became 0xA0).

One in-memory unset value: CultRecordKey.Value is never null, so
default(CultRecordRef<T>) equals a ref built from "". One wire form: an
empty key writes nil, and nil or a legacy "" reads back as default.
Load then save is byte-stable; a store holding "" converges to nil on its
next save.

Rust, TypeScript and Python have no reference type: a reference member is a
string in the consumer's own payload, so there is no sibling codec to align.
The store-composition contract now names nil as the one unset wire form.

The new test loads a record with an unset ref through a fresh cache,
re-upserts and flushes, and requires identical payload bytes; it fails on
the old formatter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:01:07 +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
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
ccf9bd0c0c Show an unkeyed struct's fields or its properties, never both
Unity's Quaternion showed eulerAngles beside x/y/z/w, Rect about eleven rows
over four values, and Bounds center/extents/size/min/max: settable properties
alias the fields they wrap. A struct with public fields now shows only those; a
struct without shows its settable properties, so record structs and CultMath
matrices (properties over private rows) still get clean rows and vectors show
fields only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:55:03 +02:00
Meta Vens
baa8de0baf Disable Studio Add for record types the model cannot create
The window lost its disabled state when record creation moved into the model:
Add and Create were enabled for abstract types and threw on click, and
CreateElement(type, type) on an abstract [Union] type threw "X is not a choice
for X". CultInspectorModel.CanCreate is now the one rule CreateDefault and the
window both read, a slot's own type is always a choice, and an abstract one
returns the "abstract or no parameterless constructor" notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:53:32 +02:00
Meta Vens
bddec0fe4d Re-read the manifest outside the unleased load's exception filter
An exception thrown inside a `when` filter is swallowed as false, so a manifest
re-read failing mid-File.Replace turned a race that should retry into a thrown
torn-page error. The re-read now runs in the handler, and a failed re-read
counts as moved. Parallel.For's AggregateException is unwrapped where the pages
are read, so the page's own failure propagates and the torn check no longer
walks wrappers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:51:37 +02:00
Meta Vens
8e0cd3e32d Compare dictionary keys under their owning document's serializer
Key identity serialized a key with the options of the key type's own
assembly, but the store writes it inside the document with the document
assembly's options, [CultCacheFormatterResolver] resolvers included. A key
that failed to serialize fell back to comparison by ToString.

The inspector codec now takes the owning document type, ReplaceKey and
FreshKey take it, and a key serializes as its declared key type under that
document's options, the store's own path. A key the document cannot
serialize is refused with a notice; the text fallback is deleted.

The comment and README said "the dictionary's own comparer"; it is the
rebuilt dictionary type's default comparer, and now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:38:24 +02:00
Meta Vens
7d59021b57 Show unkeyed structs by their writable surface, not a lock rule
The unkeyed struct rule found auto-properties by the compiler's
<Name>k__BackingField name and marked the whole struct Unsupported when
one was get-only or a field was readonly. Record structs fell out with a
generic reason, and settable or init properties beside a field were
silently hidden. Editing is in place, so the lock protected nothing.

An unkeyed struct now shows its public fields (readonly ones read-only)
and its public properties that have a setter (init-only ones read-only).
Get-only properties are computed or kept by the in-place edit, so they are
not rows; vector swizzles stay out of the inspector.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:34:56 +02:00
Meta Vens
838bbfffec Retry an unlocked directory load only when the manifest moved
A missing or hash-failing page under an unchanged manifest is corruption,
not a writer mid-commit, but every InvalidDataException was retried five
times and reported as "did not settle" with the real cause buried. The
retry now also requires the manifest bytes to have changed; otherwise the
corruption error is thrown on the first attempt. A vanished page directory
is a race under the same condition.

The reload test's writer now commits under every unleased read, so it only
settles if the retry takes the lease that commit created.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:33:43 +02:00
Meta Vens
04c7c6bcd8 Refuse a list Add that creates nothing
CreateElement returned null without a notice when the element type itself
had no parameterless constructor, and the Studio added that null. The
notice now follows the result: nothing made is refused, except the null
a Nullable<T> slot legitimately holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:31:45 +02:00
Meta Vens
12fae728c8 Reload a directory store pull that a writer committed under
A pull takes the commit lease only when .commit.lock exists. Without it
(no writer has created one yet, or the store was copied without it) the
reader loaded with no lease, and a writer committing between its manifest
read and its page reads left it with a vanished page or a stale manifest.

An unleased pull now keeps its load only if the manifest bytes it read are
still current afterwards; a changed manifest, or a named page that is
missing or fails its content hash, reloads it, at most five times, and a
pull that has not settled by then throws. A retry takes the lease once a
writer has created it. Opening still creates nothing. The lease comment no
longer claims a missing lock means no writer ever committed, and the store
composition contract states the rule.

The test deletes the lock from a seeded store and commits from a second
cache through an internal seam between the manifest read and the page
reads, once replacing the page the reader named and once only adding a key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:16:41 +02:00
Meta Vens
d22f662f7d Decide element choices and integer narrowing in the inspection model
ElementChoices says what a new list element, dictionary value or union
pick may be (a union's declared subtypes, or the slot type itself), and
CreateElement makes one or returns a notice when a subtype has no
parameterless constructor. It replaces CreateUnionValue, whose throw the
IMGUI caught and sent to the console. A dictionary with a union value
type now offers the subtypes too instead of adding a null value.

NarrowInteger clamps an integer edit to its type's range; the IMGUI no
longer converts and swallows overflow itself. IMGUI renders the model's
answers and shows its notices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:07:19 +02:00
Meta Vens
d03e376411 Delete the composite constructor heuristic from the inspector
Operator ruling: the heuristic that rebuilt unkeyed structs through a
guessed constructor is removed, not guarded. Gone: the Composite value
kind, CultInspectorShape.Composer, CultInspectorModel.Compose and
Composite, the composite-component flag on CultInspectorMember, the IMGUI
composite rebuild path, and the README text about it.

An unkeyed struct is now edited in place through its public fields. A
struct holding state it cannot write in place (a public readonly field or
a get-only auto-property) is Unsupported with a reason naming the member,
until a drawer claims it. Computed get-only properties are not state and
do not lock a struct.

The composite-rejection test goes with the heuristic; the replacement
test pins both lock rules and fails with either removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:05:30 +02:00
Meta Vens
2a46409017 Decide dictionary key edits in the inspection model
A refused key and an unavailable fresh key are now the model's answer:
ReplaceKey returns the kept key with the notice text, FreshKey returns
null with its notice, and IMGUI only shows them.

A key is taken when another serializes the same or when the dictionary's
own comparer calls them equal, so 0.0 against -0.0 is refused instead of
throwing in BuildDictionary.

Keys never actually compared by bytes: the Studio handed the model the
document codec, whose registry guard throws for any non-document value,
so every key fell back to ToString. CultCacheMessagePack now builds the
model with a value serializer, and the Studio and tests both use it.

Tests pin the comparer rule, the unchanged-key rule (an old empty
reference key survives an edit of its value) and serialized identity
(keys whose ToString collides); each fails with its rule removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:03:00 +02:00
Meta Vens
5de49ebc60 Resolve hidden inspector members to the most-derived declaration
A keyed type that hides a base member with `new` offered two candidates
for one name. The inspector now keeps the most-derived declaration, after
dropping [IgnoreMember] ones, as the registry's own-member scan does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:59:15 +02:00
Meta Vens
ba1119f18c Add the engine-free CultCache inspection model
The CultInspector* attributes move from the Unity-only
GameCult.Unity.Caching runtime assembly into GameCult.Caching, so headless
shared models carry them. CultInspectorAssetPath keeps a System.Type the
consumer names. CultInspectorDrawer claims either a value type (open
generic definitions claim every closed form) or an Attribute subclass.

CultInspectorModel owns what an inspector shows and which edits it may
make, with no engine reference:
- members: registry catalog slots for documents, [Key] slots for other
  MessagePack objects, public fields for unkeyed structs; Label, Hidden,
  ReadOnly, Order, Range, TextArea and AssetPath metadata; assignability by
  the rule the registry persists by.
- shapes: string, integer, float, bool, enum, composite (an unkeyed value
  type rebuilt through the constructor whose parameters match its members,
  which covers the CultMath vectors, rect and Color32 without naming
  them), CultRecordRef<T> with candidate records, rank-1 list or array,
  dictionary, union limited to declared [Union] subtypes, nested, and
  unsupported with a reason (multi-dimensional arrays included).
- CultInspectorDrawerClaims: attribute claims, then type claims, then
  built-in; invalid drawers and contested claims are errors and draw
  nothing.
- dictionary keys by serialized identity (a record reference by its key
  string, null and "" alike), refusal of null, empty-reference and
  duplicate keys, fresh keys, and collections rebuilt as new instances.
- CultInspectorEdit: a codec clone of the stored document; Commit upserts
  the copy, so a refused upsert leaves the cached object unchanged.

ICultRecordRef exposes CultRecordRef<T>.Key without T, replacing the
Studio's by-name GetProperty lookup.

Tests (CultInspectorModelTests): member list and metadata, claim
resolution order, declared-only union choices, record-reference
candidates, dictionary key refusal, refused commit leaving the cached
document unchanged, and shape classification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:43:45 +02:00
Meta Vens
b6ed9e5f83 Cover GetByIndex resolving through assignable types
GetByIndexMatchesAssignableTypes: a [CultIndex] on a base member of two
derived documents resolves GetByIndex<Base> to the derived record, throws
naming both keys once the value is ambiguous across the two types, and
still resolves by the concrete type. GetByIndex<T> already filtered
assignable types; no CultCache change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:43:45 +02:00
Meta Vens
debe616716 Open a directory store without creating anything
Opening a directory store, read-only or writable, created the .records
folder and its .commit.lock before reading a manifest that might not
exist. Only a write creates them now: PullAll takes the commit lease with
create: false, which opens an existing lock and returns no lease when
there is none (no writer has ever committed there, so there is nothing to
exclude). CommitBatch and PushAll create the folder and lock as before.

Test DirectoryMessagePackBackingStore_Open_CreatesNothing_UntilAWrite:
opening a missing store and a manifest-less .records folder, read-only
and writable, leaves the filesystem unchanged; the first flush creates the
manifest, the lock and one page. With the previous lease code restored
the test fails on the first read-only open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:43:45 +02:00
Meta Vens
5bd4e4ed6e Persist get-only and readonly members the way MessagePack does
The reflective registry now decides persistence from the same two facts
MessagePack 3.1.7's DynamicObjectResolver uses: whether it can assign a keyed
member after construction, and which constructor it picks.

- Every keyed public member is discovered, get-only properties included.
  MessagePack writes them all; the registry used to leave a get-only property
  out of the catalog and schema id even when a constructor filled it.
- MessagePack picks the [SerializationConstructor] if marked, otherwise the
  longest visible constructor whose parameters take the keyed members by
  position (probed: a longer non-matching constructor falls back to a shorter
  one). A member it cannot assign is read back only when its slot is below
  that constructor's parameter count.
- A readonly field is assignable under AllowPrivate (probed: round-trips with
  only a parameterless constructor), and otherwise only through the picked
  constructor. Keyed readonly fields that constructor fills are accepted.
- A keyed member MessagePack writes but never reads back is refused with a
  message naming the constructor position: a get-only property, a readonly
  field without AllowPrivate, or a non-public setter without AllowPrivate,
  when the picked constructor does not fill it (probed: loaded as default).
- An unkeyed public get-only property is refused; MessagePack throws "all
  public members must mark KeyAttribute or IgnoreMemberAttribute". The unkeyed
  message now names both fixes.

Schema ids change for five GameCult.Eve.Surface documents, whose keyed members
are all get-only properties filled by a [SerializationConstructor]:
gamecult.eve.surface, gamecult.eve.asset_catalog,
gamecult.eve.command_invocation, gamecult.eve.command_receipt and
gamecult.eve.provider_advertisement (EveProviderAdvertisementDocument). Their
catalogs gain those members. No .cc store under F:\Projects holds records of
these schemas: the gamecult.eve.provider_advertisement catalog in Gjallar's
scratch smoke stores belongs to GjallarProviderAdvertisementRecord, which has
no get-only members, and every other hit is prose or a git tree listing.

Consumer registration scan (CultLib src/tests/tools/samples, Ymir.Core,
Gjallar, AquaSynth Core/Faust, Mimir): 148 types register; the two rejections
(CounterState string key, BenchItem without [MessagePackObject]) are rejected
on 9139bf3 too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:21:21 +02:00
Meta Vens
9139bf3195 Make registry member visibility follow MessagePack's
The reflective registry now accepts what MessagePack 3.1.7 round-trips for a
[CultDocument] and refuses what it would silently lose or throw on:

- A property is writable when its setter is public, or any setter (private,
  internal, init-only) under AllowPrivate. A public getter with a non-public
  setter on a type without AllowPrivate is refused: MessagePack wrote it and
  loaded null. Private and init-only setters on AllowPrivate types, previously
  over-rejected, are accepted and round-trip.
- A [Key] on a non-public member of a type without AllowPrivate is refused;
  MessagePack skipped it silently.
- A class with no constructor MessagePack can pick is refused (the primary
  constructor case). The check mirrors DynamicObjectResolver as probed: the
  [SerializationConstructor] if marked, else any public (any, under
  AllowPrivate) constructor whose parameters take the keyed members by
  position with an assignable type.

Tests: InternalSetterWithoutAllowPrivate, KeyedNonPublicMemberWithoutAllowPrivate,
AllowPrivatePrimaryConstructor, AllowPrivateSettersRoundTrip (byte-matched
against MessagePackSerializer); InheritedPrivateSetter carries the new message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 00:03:23 +02:00
Meta Vens
96ee5fdce3 Delete the CultCache MessagePack source generator
The reflective CultDocumentRegistry is now the only descriptor authority,
and payloads always go through MessagePackSerializer with
OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed,
Unity always ran the reflective path, and no benchmark showed a gain,
while the generator duplicated the registry's shape rules.

Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution,
project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata
(provider attribute, interface, definitions, loader), the generated descriptor
and codec branches in CultCache and CultDocumentMessagePackSerialization,
DocumentShapeSweepTests and the generator half of the unkeyed test, the
Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo.

The registry rejects, with a message naming the fix, what MessagePackSerializer
cannot serialize and what the generator used to paper over: a document without
[MessagePackObject], a non-public document without AllowPrivate = true, and a
non-public member of an AllowPrivate document that is not [IgnoreMember].
DocumentShapeTests pins every rejection message and the accepted shapes'
round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin).
Rejected shapes are emitted into a dynamic assembly, which registry
discovery skips.

Nested member type names use reflection's Outer+Inner form, not the
generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned
(bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc
stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj
excluded) found no member type name naming a nested type in either form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
Meta Vens
a5d1be650d Mark every Cult document [MessagePackObject]
PlayerData, CultMeshSimulationFact, the Networking interop peer's
CultNetInteropNote, the cultnet-py benchmark item and the Caching test
documents lacked [MessagePackObject]. Only the generated codec could
serialize them; MessagePackSerializer refuses such types. Non-public test
documents take [MessagePackObject(AllowPrivate = true)], as consumer
internal documents already do, and ConditionalCommitTests.Slow marks its
private backing fields [IgnoreMember].

Same [Key]s, so payloads are unchanged: a pre/post probe over the
Networking assembly and both interop peers showed identical schema id,
content hash and payload bytes for all 9 documents, PlayerData equal once
its Guid.NewGuid() default id is masked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
Meta Vens
30bf7fd267 Make MessagePack [Key] the slot authority and pin generic resolvers
.cc is MessagePack-based, so the core no longer pretends to be
serializer-independent. GameCult.Caching references
MessagePack.Annotations 3.1.7 and reads KeyAttribute and
IgnoreMemberAttribute by type instead of matching their names. The
benchmark's own MessagePack.Annotations reference is dropped; it arrives
through GameCult.Caching.

A generic document's options come from its generic definition's
assembly: Doc<X> uses Doc's declared resolvers, never X's.
GenericDocumentUsesItsOwnAssemblyResolvers compiles GenericHolder<T>
into an assembly whose resolver writes Pair as [B, A] and checks the
payload uses it rather than Pair's own assembly resolver.

Contracts: schema-compatibility states the slot rule and its
rejections; store-composition states the generic resolver rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:06:17 +02:00
Meta Vens
7c4bc2bdc0 Make generated and reflective document members one rule
The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).

- Accessors are named per document, so documents sharing a [CultName] or
  [CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
  properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
  or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
  declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
  re-keyed override and serializes an override marked [IgnoreMember]).
  An override that changes them is refused in both paths; an override
  that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
  generated codecs and declared resolvers; deleted. CultNetDocumentBinding
  and CultMeshSnapshots route through the untyped path with their registry.

DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:00:16 +02:00
Meta Vens
9009aa79bc Generate derived documents' members and require [Key] on every one
The generator walked only members declared on the document class and emitted
a codec only when those were dense, so a derived document's generated
descriptor hashed a different schema id and catalog than the reflective one.
It now discovers members along the base-type chain the way
CultDocumentRegistry reflects them (public instance fields and get/set
properties, inherited included, [IgnoreMember] excluded), and density is
over that full set: RoutingWeapon : RoutingGear gets a codec and the same
SchemaId and catalog (Name@0, Damage@1) from both builds.

Unkeyed members made the two orderings disagree (generator by name, registry
by metadata token). Both implicit-slot paths are deleted: an unkeyed
persisted member of a [CultDocument] type is generator error GCC001, and the
registry throws the same message. The consumer audit found no unkeyed
document with a persisted store.

Tests: GeneratedAndReflectiveDescriptorsAgreeForDerivedDocuments failed on
ea5a07b ("no generated codec for the derived document") and passes through
UpsertWeaponThroughGearHandleReloadsAsWeapon; UnkeyedDocumentMemberIsRejected
drives the generator through Roslyn and compares its diagnostic with the
registry's exception. The registry's reflective BuildDescriptor is internal
and visible to the tests for that comparison. The registry-collision fixture's
emitted field gains its [Key]; schema-compatibility fixture ids are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:19:08 +02:00
Meta Vens
ea5a07b37a Own serialization options by the document's assembly
The static UntrustedData options are gone. An assembly declares its formatter
resolvers with [assembly: CultCacheFormatterResolver(typeof(R))];
CultDocumentMessagePackSerialization.OptionsFor(assembly) composes them ahead
of CultDocumentResolver and StandardResolver, once per assembly, and hands
assemblies that declare nothing the base Options. Generated codecs and the
reflective SerializeUntyped/DeserializeUntyped path both read
OptionsFor(typeof(X).Assembly). No registration call, no mutable options, no
load order.

CultMessagePackSecurity copies UntrustedData and supplies a collision-resistant
comparer over CultRecordRef<T>.Key.Value, so ref-keyed dictionaries
deserialize.

Tests: RefKeyedDictionaryRoundTrips failed on 825b4f7 ("No hash-resistant
equality comparer available"); DeclaredResolverEncodesValueTypeAsPositionalArray
and UnknownAssemblyGetsBaseOptions did not compile there (new API);
InteropNoteBytesUnchanged is a pin whose payload constant was captured on
825b4f7. BenchItem, the one unkeyed [CultDocument] in any consumer (in-memory
benchmark, no store), gains [Key]s in its existing slot order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:12:53 +02:00
Meta Vens
6fcc2a7704 Stop MessagePack's source generator flowing to consumers
A direct MessagePackAnalyzer reference with PrivateAssets="all" in
Caching.MessagePack and Networking keeps MessagePack 3's generator out of
consumer builds, where it emitted a reference to a nonexistent
GeneratedMessagePackResolver formatter for Dictionary<CultRecordRef<T>, float>
(CS0426). CultLib composes StandardResolver itself and never registers a
generated resolver.

tests/GameCult.Caching.ConsumerBuild is RefKeyedDictionaryCompilesInAConsumer:
its build is the test. On 825b4f7 it failed with CS0426; now it builds and
obj has no MessagePack.SourceGenerator output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:09:16 +02:00
Meta Vens
825b4f7d26 Mirror snapshots carry the cache Sequence they reflect
ObserveAsync, ReactiveAsync and RefreshAsync read a snapshot without a
Sequence, so an older change admitted before the read but delivered after it
overwrote the newer value.

CultCache.GetWithSequence(key) returns the document and the cache's Sequence,
read together under the gate. A cache-backed CultMesh handle passes it to its
mirrors; each mirror subscribes first, then adopts the sequenced read under its
own gate and takes that Sequence as applied, so a straggler at or below it
drops. RefreshAsync on both mirrors uses the same adoption path. Public Mesh
surface unchanged. The read is non-generic because the Mesh route needs the
untyped document for same-schema conversion.

Contract: only Watch/WatchRecord changes carry Sequence; OnUpdate-derived
CultNet streams have no ordering or stale protection; schema-alias handles and
removals are not stale-protected; the sequence constructor argument changed
CultCacheDocumentChange<T>'s binary signature.

Tests: ObserveAfterNewerSnapshotIgnoresStraggler and
RefreshIgnoresStragglerOlderThanSnapshot (observed and reactive cases) fail on
4562340; SequencedReadReflectsEveryChangeAtOrBelowItsSequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:02:49 +02:00
Meta Vens
456234018f Carry order as data: per-cache change Sequence, no delivery scheduler
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.

The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.

Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.

Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.

Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:43:11 +02:00
Meta Vens
bcae4839d0 Deliver each cache's changes in save order
The outermost hold takes a ticket under the gate and, after releasing it,
waits on a per-cache delivery gate for its turn, delivers, then advances
the turn. Delivery order across threads equals admission order. A hold
closed while its thread is delivering queues its ticket behind that
delivery on the same thread instead of waiting, so an observer writing
the cache cannot deadlock against a writer: a waiting thread holds no gate
and no turn, and a turn holder never waits.

Hand-over-hand from the gate to a delivery lock was rejected: a writer
blocked on the delivery lock while holding the gate deadlocks with an
observer that writes the same cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:09:23 +02:00
Meta Vens
b653f68f81 Route every admitting gate entry through a hold
FlushAllBackingStores and Dispose take the gate through Held, so a store
method that loads can never reach Admit under a plain lock; Admit outside
a hold throws a clear InvalidOperationException. Entering a cache's hold
inside another cache's hold throws. PullAllBackingStoresAsync pulls every
store even when an OnUpdate handler throws, then rethrows. The dead Try
around Subject.OnNext is gone: R3 never throws there, so only OnUpdate
exceptions reach the caller. Repull tests wait for the writer to start
before their bounded wait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:01:36 +02:00
Meta Vens
544c087cf1 Publish each hold's own changes to its own caller
The cache-wide unpublished list let any thread drain and deliver another
thread's changes: a throwing observer dropped the rest of the queue and threw
into an unrelated writer, writes returned before their change was published,
and a third-party load could wait indefinitely.

Every admission now runs in a hold (Held). The outermost hold on a thread
collects exactly the changes it admits and publishes them after it leaves the
gate, before it returns, on its caller's thread; nested holds append to it.
Every change is delivered even when an observer throws; the first exception
(AggregateException for several) is then rethrown, and a failing body's own
exception wins over observer exceptions.

Stores take the hold instead of locking a shared Gate object, so a direct
PullAll on an attached store publishes its own load and the empty follow-up
Loaded hand-over is gone. Gate is replaced by protected Held.

Tests: OwnChangeIsPublishedBeforeWriteReturns and
ThrowingObserverDoesNotLoseOtherChanges fail on 4eacd87 and pass here. The
repull tests now record a write that reaches the gate while the pull is paused
instead of inferring readiness from thread state; both fail when the pull does
not hold the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:46:18 +02:00
Meta Vens
4eacd876bf Publish observers after the gate, gate every store entry point, stamp under one hold
Observers and OnUpdate never run under the cache gate. Admit queues changes
while the gate is held; the outermost entry point (attach, pull, commit,
upsert, remove) publishes them after releasing it. A store's direct PullAll
hands over an empty load outside its lock so the cache publishes then.

A throwing observer can no longer split store from cache: publication starts
only after the store has adopted its loaded view. The directory store adopts
its durable catalog only after the cache admits the load; the write-only
_durableIndex is deleted.

Single-file Push, Delete, PushAll and CommitBatch take the gate like the
directory store; Dispose flushes under it. Write mints storedAt and admits
under one gate hold, and Land re-stamps staged upserts under the gate that
admits them, so a concurrent pull cannot land a later storedAt in between.

Tests: ObserverDoesNotRunUnderTheGate,
ThrowingOnUpdateHandlerDoesNotResurrectDeletedRecord,
RepullNeverErasesAStagedWriteOnDirectoryStore,
DirectCallsOnAnAttachedStoreShareTheCacheGate; the directory last-writer
result is pinned to {a,c,x}; the single-file re-pull test waits for the write
to reach the store or block instead of sleeping. Pausing tests always release
the pause so a failure cannot deadlock Dispose.

Docs: observer rule, direct store calls share the gate, directory TryCommit
contention and gate wait, per-key last-writer-wins, directory flush merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:23:44 +02:00