Commit graph CultLib/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs
Author SHA1 Message Date
Meta Vens
64b86e0a27 write-ownership-cs: a write owns a record by the written type; one-batch retype is eviction then add (squash of hands/variants-write-ownership a4eae905..8c7252ea)
A write owns the record at its key when the written type declares the
record's schema id, or reads an older undeclared id of its own schema name
whole (ruling own-entry-reads-whole); every other registered type's record
is refused, in every cache, with or without a backing store
(cache-refuses-retype).

Removing a record and adding one of another type in a single batch is
staged as the held record's eviction then the add, so validators, Claim,
the store judges and the change journal see what they see staged apart
(one-batch-retype-is-eviction-then-add); Watch<old> gets Removed and
Watch<new> Added. A one-batch retype across home stores is refused by the
batch-spans rule and must be staged apart. A variant rebase changes type
exactly where a plain write may (variant-rebase-follows-plain-write).
Store kinds outside the library get one protected per-key judge
(store-kind-per-key-judge), exercised by tests/GameCult.Caching.OutsideStoreTests.

Spec variants:cut_spec:cut-write-ownership-cs.r1; reports h1-h5;
Soul s5 clears the merge (verdict variants:verdict:cut-write-ownership-cs.s5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:22:20 +01:00
Meta Vens
f87d12b9ae Cut the dead StoredSchemaReadWhole defaults and Wrote sets; test the refusal text, the cacheless declaration and the unnameable claimant
Stryker found the true default and the two Wrote sets equivalent: a constructed or written record is stored under its type's declared id, so the flag is never asked. Not run at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 05:34:43 +01:00
Meta Vens
0fbc12275a Restamp on write: one ownership rule, Owns, under which a type that reads a stored record whole may write it
CultDocumentRegistry.Owns replaces Declares as the one answer to whether a writer may write, relabel or remove a record under a
schema id: the id is declared, or the type that reads it reads it whole (equal content hash, or compatible shapes with no persisted
slot ignored). Staging asks the declared half or CultStoredDocument.StoredSchemaReadWhole; the apply halves (single file,
directory, cacheless CultMesh) ask Owns over the durable catalog, once per distinct id. A record read only by dropping slots
stays read-only until its id is declared compatible.

Not built or run at this commit: verified on Yggdrasil at the cut's head.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 03:39:02 +01:00
Meta Vens
52e6095f5c Pin the stamp rules: empty-array directory manifest refused, stamp-first rows name their refusal, DescribeHeader cases
DirectoryMessagePackBackingStore judges the parsed snapshot's stamp after the stamp-first read (an array with no stamp parses to v1 and was opened as a store). The NewerStampOverUnreadableBody rows assert the fragment each refusal names, plus a binary stamp over a valid body. DescribeHeader gets its suffix, digit-bound and zero-digit cases. Owns findings empty-array-directory-manifest-opens, stamp-first-unpinned-at-store-paths, describe-header-suffix-unpinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:53:10 +02:00
Meta Vens
f2cdb7897d Land fixes: directory flush records the schema id it wrote; ported drift and variant tests meet C2a's write set and typed refusal
Directory store now sets StoredSchemaId with StoredVersion after a page write (found by main's OwnPushOfALoadedDriftedEntryKeepsExpectHolding). RecordRewrittenUnderAnotherSchema stages the record, since a flush writes only the write set. Variant conflict test expects CultWriteConflictException.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:59:54 +02:00
Meta Vens
f2b3569822 Merge main at cultlib-unity-v2.0.0 (42dce361) into C2a: one owner per collision
Identity: C2a's StoredSchemaId/StoredVersion owns; main's PersistedSchemaId, PersistedIdentity and both MarkWritten are deleted. Stamp rule: ReadStore judges the stamp before the body. Flush over damage refuses (merge-r1-precedence). Mesh single-file writes route through SingleFileMessagePackBackingStore. TS: store-file.ts commits, C2a's header handling kept. Rust: C2a's readable_store_format in front of the grown decoder. Build not yet verified at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:35:33 +02:00
Meta Vens
39e8ab5efd Single-file and directory snapshot reads judge the format stamp before parsing the body; a flush heals an empty or truncated file
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 17:30:57 +02:00
Meta Vens
d44188637d Caching: a plain flush and a directory write refuse a store another process stamped newer since attach
Single-file PushAll reads the format stamp under the lock and refuses cultcache.store.v<N> with N > 2;
the directory store throws NotSupportedException for N > 4 from the manifest parse every write already runs.
An unreadable stamp keeps today's behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:43:11 +02:00
Meta Vens
73d438dbcf CultCache: every backing store marks the records it rewrites, through a protected CacheBackingStore.MarkWritten
The directory store's write of a generation marks each page it wrote once the
manifest names it, so a Push+PushAll of a loaded drifted entry no longer leaves
its persisted schema id stale. The single-file store marks through the same
member, and a store outside GameCult.Caching now has a supported way to mark
its own rewrite. The getter's doc names how a store records a rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:41:53 +02:00
Meta Vens
3f6dda8b36 CultCache: conditional commit compares the schema id a record is persisted under
The six reads that took Descriptor.SchemaId as a record's durable identity (ConditionsHold, Expect, the store-less commit, both pull filters, JudgeMerge) now read CultStoredDocument.PersistedSchemaId. The store sets it from the record it read, a stamped entry starts at its descriptor's id, and a completed whole-file write (single-file PushAll, whole-view CommitBatchCore) resets it under the hold. A variant and its resolved view share it. An unchanged record loaded under compatible drift now satisfies Expect and ExpectUnchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:05:23 +02:00
Meta Vens
e5736e49f3 CultCache: a write that copies a record it read holding an element id marks the store
The single-file store records, per pull, which plain records decoded holding a non-empty
element id and at what version. A write whose copied record is still at that version
marks the header (v3). The record is positive-only and version-gated, so nothing a load
records can unmark a store. The directory store passes false: only C# writes it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 21:52:36 +02:00
Meta Vens
696a712088 JudgeChecks (directory): drop the CreateDirectory the commit lease already makes; tests for a never-written non-home store and for a check-less batch beside a held lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:23:14 +02:00
Meta Vens
1893410fe1 Every writable store judges a batch's unheld removals under its own lock; a foreign record's version comes through Foreign
CacheBackingStore.JudgeChecks (default: the view; single-file and directory stores read under their lock) replaces Land's view-only loop over non-home stores. Foreign takes the version the store read; CultForeignRecord.StoredVersion is get-only and set by the ctor, and every assignment after Foreign is deleted. One commit carries both spec commits 1 and 2 (the edits share hunks); unverified until the final job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:07:19 +02:00
Meta Vens
3a18f01a83 Caching: a removal of a key the cache does not hold is a check-only key judged under the store's lock; ExpectUnchanged compares foreign records too
Remove and Commit of an unheld key no longer read the store at the call: the key travels as CultCommitRequest.Checks (commit) or a staged check (flush),
and each writable store judges it in RefuseUndeclaredOnDisk with the keys it writes, never removing it. A batch that only checks writes no file.
CultForeignRecord.StoredVersion records the version a store read, and the one ConditionsHold takes the store's foreign records.
Folds spec commits 1 and 2 (their hunks share the same methods). The src projects build; tests/GameCult.Caching.Tests does not at this commit
(CultCommitRequest gained a parameter; the next commit updates the test).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 04:03:41 +02:00
Meta Vens
43a63b0792 Removing a foreign record is judged against the store as it is, not the last load
A removal of a key the cache does not hold writes nothing, so it never reached
the apply; it was checked against the foreign list of the last load, and a
foreign record that arrived since was reported removed. Each store now reads
its durable state for such a removal (RefuseRemovalOfUndeclared) and refuses
through the apply's own RefuseUndeclaredOnDisk, which reloads and lists the
record. The load-time check (RefuseRemovalOfForeign) is deleted: the durable
read refuses everything it refused, and it could not save that read. A store
kind that cannot read its durable state keeps the view check as its default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:13:32 +02:00
Meta Vens
b636a69f9d A refusal lists what it refused foreign, as a pull lists it
Refuse reloads a refused key as a pull loads it, and now that includes the
store's foreign list: a record another writer moved to an id this cache cannot
read is listed in ForeignRecords after the refusal, so the next write of that
key is refused at staging instead of again at the flush. Both store kinds give
Refuse the durable record's foreign entry (ForeignAt); the variant refusal too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:13:07 +02:00
Meta Vens
067df993a3 Apply refusal: a write over a durable record under an undeclared id is refused at ApplyWriteSet and reloads read-only
ForeignOnDisk and the two RefuseForeign calls in PushAll and CommitBatchCore are
gone. ApplyWriteSet refuses first, over every key it writes or removes, by
Registry.Declares for a cache and by the catalog entry it writes for a cacheless
CultMesh write. Both refusals reload through one DurableAt; the directory store
reloads through LoadPage, the per-record body of LoadRecordPages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:11:23 +02:00
Meta Vens
e9a668769a Ownership by declaration, staging half: a write that would change a record under an id no registered type declares is refused before staging
Declares (registry) is the one answer to whether a cache may write, relabel
or remove a record stored under an id. RefuseUndeclared applies it from
Validate's Claim, which every cache write passes, to what the home store
holds at the key: a foreign record or one held under an undeclared id.
RefuseOverwrite and its two Push calls are deleted.

Red until rewritten: ForeignRecordTests.AWriteOfTheRecordItselfStoresItUnderTheTypesOwnId
and CompatibleSchemaIdTests.AnotherNewerCacheRestoringTheIdAfterAnOlderRewriteStillFailsTheCondition,
which pin the relabel rule 3 forbids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:31:53 +02:00
Meta Vens
2a3773a463 CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.

SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.

A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.

Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
Meta Vens
b195525d64 CultCache: a record's version is the SHA-256 of its stored bytes; conditions, pulls, the staleness check and a refusal's reload compare it, never storedAt
Adds CultStoredDocument.StoredVersion, CacheBackingStore.VersionOf and the
SingleFileBackingStore.SerializeRecord hook, and swaps every identity comparison
in the same commit: SameStoredIdentity stays the one comparison, over versions.
Deletes the Identity helper, the (schemaId, storedAt) Expected tuple, the
in-memory fake-record projection and the directory's storedAt arrival.
Breaking public API for 2.0.0: CultCommitRequest.Expected, ConditionsHold and
the abstract SingleFileBackingStore.SerializeRecord.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:23:31 +02:00
Meta Vens
25d6cb2951 C# store header: the durable header owns "holds element ids"; StoredHoldsIds and KnownIdFree are deleted
A record's bytes cannot say whether it holds an element id (an id is an ordinary keyed
string), so the per-record fact decoded by whichever type loaded the record was a split
authority: a narrower type unmarked a v3 store over bytes holding ids. HeaderForWrite is now
a pure function of the durable header, the HoldsIds of the records the write encodes, and
whether it copies any record forward: a write that copies keeps a marked header, a write
that encodes every record decides by content. The directory store takes the same rule.

Also pins the three Low test findings deferred from c2a-write-set s3: the reload cause is
observed by type and message, the author's own Expect after a commit, and the directory
store's re-mint branches.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 07:04:48 +02:00
Meta Vens
2e3312635d C# write set: one re-mint for every store kind, and a foreign refusal carries its reload failure
The directory store wrote an earlier storedAt over a later record it replaced, against the contract.
CacheBackingStore.StoredAtOver is now the one re-mint; the single-file apply and the directory
generation write both call it. A foreign refusal passes the reload's cause to
CultSchemaConflictException as its inner exception. Per-commit build not verified.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 06:05:41 +02:00
Meta Vens
a7114a1f9f C# write set: tests for copy-forward, raw catalog carry, header rule and the inverted lay-back rules
New WriteSetTests (single-file and directory stores, flush and commit): another writer's write and
removal survive, a store that appeared after open is kept, an unstaged in-place change is not
written, a copied record keeps the catalog entry the store holds now, an entry's unknown trailing
fields survive byte for byte, conditional and unconditional commits write the same bytes, the
header drops only when every copied record is known id-free, v2 exactly with a variant. Mesh: a
write copies neighbours forward and replaces only its key. The lay-back and whole-view tests and
the flush-restamp tests are rewritten or removed with the rules they pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:16:49 +02:00
Meta Vens
aa186513a5 C# stores: every flush, commit and CultMesh single-file write applies a write set to the durable store
SingleFileBackingStore.ApplyWriteSet replaces WholeView and ReplaceDurable: under the lock, from the
durable snapshot read there, it removes staged removals, writes staged upserts and copies every other
record and the catalog entries they reference unchanged. PushAll, CommitBatchCore (conditional and
unconditional, one apply) and CultMesh's write call only it. Catalog entries read from a store keep
their exact bytes and are written verbatim while their decoded fields are unchanged. Derive no longer
lets a registered entry publish an id nothing staged sits under. HeaderForWrite is the one header rule
for the single-file and directory stores. A write is judged against variants another writer left in the
file. Tests of the deleted rules are rewritten in the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 03:05:08 +02:00
Meta Vens
425531d88e C#: a dangling link is an I/O error on read; the directory refusal echoes a header only as store version digits; contract names Rust's real type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:49:12 +02:00
Meta Vens
86eea40bce One owner for the foreign-record refusal: it reaches the cache, names every key, and refuses only what the cache wrote
CacheBackingStore.RefuseForeign now decides, for both store kinds, which written keys a foreign record refuses, drops the
store's staged change and the cache's copy of each through the Loaded path a pull uses, and throws one conflict naming every
refused key. The single-file store tracks the keys staged since its last load or write, as the directory store already did, so
a clean copy of a record another writer made foreign is carried, not refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:34:09 +02:00
Meta Vens
b0f7d59c96 Directory store: a refused foreign overwrite leaves the pending set with its failed write
WriteGeneration left the refused key in the dirty and deleted sets, so every later generation
refused it. The refusal is now one check before any staging, shared by flush and commit, that
removes the refused keys from the pending sets. Tests cover both store kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:51:54 +02:00
Meta Vens
05221dae74 C#: a record no registered type claims is carried untouched, and a write over it is refused
C2a F2, operator: "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."

- The registry's claim decision (Claimant) is split out of resolution. It
  never decodes or compares shapes, so a store classifies a record from its
  header. The last branch, "No local CultCache schema matches", now means
  foreign instead of a refused load.
- Both store kinds keep a foreign record out of the cache and list it in
  CacheBackingStore.ForeignRecords (key, schema id, catalog name, storedAt).
- Single-file store: a whole-view write lays back every foreign record the
  file holds now, with its catalog entry, and keeps a marked header while it
  carries one (it cannot see their ids). A staged write, a commit (onto the
  file or whole view) and a whole view over a key the file holds as foreign
  refuse with CultSchemaConflictException naming the key and id. A merge
  judgement never decodes a foreign record, and one that replaced a held
  record takes it away.
- Directory store: a foreign record's page is never read; a generation that
  would replace or remove one refuses before any page is written.
- Contracts: hard rejection no longer lists "no compatible local schema
  candidate"; the persistence format says what a foreign record is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:50:15 +02:00
Meta Vens
5607d9703d C#: the directory store's loaded id is its manifest's; the variant fixture names itself
A directory-store record's identity on disk is its manifest entry, which is what
a commit condition compares; the page holds its own copy of the id. A load now
takes StoredSchemaId from the manifest, so a manifest rewritten under an older
id (AConditionOnARecordUnderADeclaredOldIdHoldsInTheDirectoryStore) is the id
the condition expects. Canonicalisation had hidden the page and manifest
disagreeing in that fixture.

The whole-view test's variant overrides its name: a variant may not share its
base's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:52:15 +02:00
Meta Vens
5c8af219f9 C#: a commit condition compares the id a record was loaded under with the file's, exactly
Each stored document carries StoredSchemaId: the id its record sits under in its
store, the one it was loaded under until a write puts it under its type's own id.
Expect, ExpectUnchanged, the reload and merge identity checks compare
(StoredSchemaId, storedAt) with the store's bytes exactly. CanonicalSchemaId is
deleted: canonicalising the disk id let an older version's whole-view rewrite
(same storedAt, restamped to its own id, a member shed) pass a newer cache's
condition (Soul F1), and failed every condition on a record resolved by name or
through a file entry listing the local id (F3). A whole-view write marks every
record it wrote as stored under its type's id, in the store and the cache.

Tests: Soul's rolling-deploy probe as AWholeViewRewriteByAnOlderVersionFails...
(Mismatch for Expect and ExpectUnchanged, nothing derived lands, a pull shows the
rewrite and the retry commits); F3's H1, H1b, H2 pass Expect and ExpectUnchanged
when nothing changed; conditions after a whole-view commit hold for a plain
record and a variant. BareDeck is emitted at run time (EmittedDocumentTypes) so
the shared registry never sees it beside DeclaringDeck; the CanonicalSchemaId
tests and their Claimant fixtures go with the function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:35:40 +02:00
Meta Vens
186e5b29ae C#: a document type declares the older schema ids its records may sit under
CultDocumentAttribute.CompatibleSchemaIds, as TypeScript and Python already name it. The
registered catalog entry lists the ids after its own, the registry resolves a record under one
to the type (its own id first, then the first registered type that declares it), and a
condition on a record compares schema ids through that resolution, so a record still under a
declared old id counts as the record the cache holds. The cache already stamps every record it
writes with the descriptor's own id; the store writers still change no identity, so a record a
commit does not rewrite stays under the id the file holds it under, published by the registered
entry. Without the declaration such a commit is refused, typed, as before.

Contract and XML docs describe the declaration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 11:30:05 +02:00
Meta Vens
9cee5e3f42 C#: no store writer restamps a record; the directory store has one source of arrived entries
Deleted the heuristic that rewrote a record the cache held "unchanged since read" (same key and
storedAt) under the registered id, in the single-file commit and in the directory generation. It
could overwrite a record another writer changed while keeping its storedAt. A commit onto a file
now writes its batch and leaves every other record as the file holds it; a record under an id only
an arrived entry lists is refused, typed, with the file left as it was. Deleted the directory
store's _durableCatalog, a second stale-able source of arrived entries beside the manifest read
under the lease.

Tests: the lost-update probe, the two refusals that replace the restamp tests, the
arrived-overwrites-registered guard in both id orders, the content-hash tie-break against the
compatible-list order, and refusal determinism across record arrival order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 11:04:12 +02:00
Meta Vens
e77246b657 Writers derive the catalog from the records they write; the merging is deleted
Every writer (C# single-file and directory, TypeScript, Python; Rust already did) chooses, for each schema id a record carries, one
entry that publishes it: the entry that owns the id (a registered descriptor, else an arrived entry), else one that lists it as
compatible. A registered descriptor wins over an arrived entry with the same own id. Entries are written as chosen, with no union of
compatible ids and no dependence on arrival order; an entry no record needs is not written. Two entries of one tier that share an own id
and disagree on the schema name, records of different types under one id, or a record no chosen entry publishes refuse the write with a
typed error (CultSchemaConflictException, SchemaConflictError) naming the id, the names and a record key, and the store is left as it was.
CultSchemaCatalogEntry.MergeById and the Python and TypeScript merge paths are gone. A cache-held record unchanged since the file was read
is rewritten under its registered id (C# single-file and directory), so an entry that lists an older id is not needed to publish it.
Changelogs for cultnet-py (removed export) and cultcache-py.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:03:34 +02:00
Meta Vens
f96a0e7f7a Catalog writers merge entries that share an id; every reader resolves an id by its owner first
Entries that share a schema id are one schema seen by different writers, so the writer lists every id any of them lists, in any
order: C# single-file and directory (CultSchemaCatalogEntry.MergeById), TypeScript and Python. Rust synthesises one entry per id and
has no such merge. An id that one entry owns and another lists as compatible names the owner in Rust, TypeScript, Python and the TS
inspector, as C# already did (own-id-over-compatible-v3). Also: a directory manifest in an unreadable format is the typed refusal
naming it, cultnet-py applies and reports a record under the local schema id, and Rust's error prints its cause once.
Vectors for the Mesh layout discriminator: a nil and an integer at catalog slot 5.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:17:59 +02:00
Meta Vens
2647f10d20 C#: a rewrite keeps the catalog entry that publishes a record's schema as a compatible id
The single-file and directory writers dropped an entry whose own id no record carried, leaving a record under a compatible id with
no catalog entry that publishes it: the same store nothing could open. An entry now stays while it publishes any id a record
carries. The conditional-commit readability test over compatible-id-only-v3 pins it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 07:02:06 +02:00
Meta Vens
969d78de39 C#: compatible schema ids publish a schema on open, Mesh takes only the older layout, the refusal names its file
A record whose schema id the catalog lists only as a compatible id now opens (the cache resolved a persisted schema by entry id
alone). CultMesh's fallback to the older catalog-entry reader fires only for a store in that layout, decided by the entry's slot 5,
not for any decode failure, so a current-layout store with a malformed slot is refused as CultCache refuses it.
CultStoreUnreadableException.Path names the refused file. New vectors: compatible-id-only-v3 (accepted by all four) and three
current-layout catalogs with one malformed slot. The contract states the writer's invariant.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:54:17 +02:00
Meta Vens
41d0dd9bfb C# header: one function decides it for every writer; a store's own ids are the only source
CacheBackingStore.HeaderFor(holdsIds, existingHeader, wholeStore, directoryStore) replaces FormatFor,
DirectoryFormatFor and the writers' private marker logic. A writer that rewrites the whole store
decides by content; a writer that touches part of it keeps a header already marked. Flush,
unconditional and conditional commit, the directory store and CultMesh's single-file write all call
it. A typed CultMesh write replaces the file with a document it can see, so it decides by content;
a raw payload is opaque and keeps a marked file marked.

A record's HoldsIds is what its stored form persists: a load keeps a variant's delta as read, so
the ids a load mints in an override exist only in memory and no longer mark the store. A
conditional commit onto the file no longer reads this cache's view of what the file held, so a
stale view cannot over-mark it. IdPlan.HoldsIds is derived from HeldIds and the fills.

Reverts InternalsVisibleTo GameCult.Caching.MessagePack and the public DirectoryFormatV4/V5
constants: the directory store reaches the decision through the protected member it inherits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:54:01 +02:00
Meta Vens
6a7d17bdda C2a fix: a flattened record is marked by the document it stores; every writer decides its header by content
A flattened variant's HoldsIds is read off the document re-resolved against the post-batch base, not carried from the
variant or from the document the flatten was planned against. The conditional commit onto a file decides by the same
rule as the unconditional commit (ids in the records the file will hold, ids only this cache minted excepted, a marked
file staying marked). The directory manifest's header choice moves beside FormatFor. Pins the three-batch flatten
probe on the commit and flush paths, the conditional commit, the last id leaving, an id-bearing override that is not
the last, and a failed CultMesh write giving its ids back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:20:01 +02:00
Meta Vens
490f7b811f C2a: mark the header by content, refuse an element twice in a list, route CultMesh single-file through the id rule
A store is written v3 (v5 for a directory) only when a record in it holds a non-empty
element id. CultElementIds.Plan reports that as IdPlan.HoldsIds; the admission stamps it
on the stored record and every store reads it. The descriptor's CarriesElementIds is
deleted. A file already marked on disk stays marked. CultPersistedStoreSnapshot.FormatFor
is the one header decision.

Plan refuses one element object sitting twice in a list at write, naming the record and
list, instead of writing both ids and refusing the store at load.

CultMesh single-file write mints, refuses and marks as a cache write does; the read applies
the load-time plan, so a write and a load refuse the same things.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:40:36 +02:00
Meta Vens
fc52f984db CultCache: element id rules, atomic minting, minted override values, the v3 marker
The id rule is one plan, made before anything is written: Plan decides every fill and
refuses a duplicate, a random id that is not 12 lowercase hex characters, and a derived
id whose source is null or empty, naming the record and list (CultElementIdException).
A load refuses what a write refuses. Apply and Undo make minting part of the admission:
a refused or uncommitted write leaves the caller's documents as they were.

Override values mint inside Resolve, into the decoded copy, so the public
CultVariantOverride.Set, C1-era overrides and Override() all get ids on write and on load;
Override() no longer mints. Interface unions are judged on their concrete types, the same
rule registration and minting share, and a subtype reusing the id's key is refused at
registration. Dictionary keys format with the invariant culture.

Records minted at load are marked per entry (IdsInMemoryOnly) instead of listed by key, so
a deleted record leaves the mark with it, and a flush of the whole store clears it. A store
whose types reach an object list declares cultcache.store.v3 (directory: v5), which every
reader older than ids refuses; a marked file stays marked when a writer merges onto it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:27:16 +02:00
Meta Vens
282e337c2c Variants C1: the held index tells the truth; a merge is judged on what the file will hold
The held index kept one key per value, last writer winning, and RemoveKey dropped
a value outright. Two consequences: removing or changing one of two plain
holders left the other unfindable, and the variant index rule judged a variant
against that lossy view, so a variant could land on a value a plain record still
held and the store then refused to reopen.

- _indexes maps a value to the keys holding it, oldest first. Index, Unindex and
  the removal maintain the set. GetByIndex finds the most recently indexed
  holder of a value, so removing the winner finds the other (behaviour change for
  plain duplicates: it used to find nothing).
- One rule, RefuseVariantIndexSharing, serves both write orders. It reads the
  truthful index minus what the commit replaces or removes, plus what it lands.
  ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants
  and RefusePlainSharingWithVariant are gone.
- A conditional commit merged onto the file is judged by the cache's own
  resolution (CacheBackingStore.Judging) on the merged set: records other writers
  added, records they removed, and the batch. Another writer's variant sharing a
  value with the batch, or a base removal that strands another writer's variant,
  is refused with the named refusal instead of writing an unopenable store.
- Shedding is one decision at load. ToStoredDocument resolves the schema once and
  returns the report with it; a variant's override of a slot the type lacks is
  dropped from the delta there, so flush needs no predicate and every store
  reports through the same surface (ReportVariantSheds and OverridesTheTypeHas
  are gone). The directory store refuses variants and loads through the same call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
Meta Vens
c66d79e248 Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused
F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.

F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.

F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.

F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.

The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 06:58:51 +02:00
Meta Vens
dbc87b9447 C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.

The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +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
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
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
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
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
Meta Vens
5aad137e61 Share the cache gate with its stores and make unconditional commit last-writer-wins
An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.

An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.

Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.

Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 18:45:27 +02:00