CultLib/docs/c2a-land-map.md
Meta Vens 8ee6671410 c2a-land map: refresh onto 193cf1c3 (restamp r2, gate r6) and the landing sequence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:31:13 +01:00

26 KiB

C2a land map

What C2a (hands/variants-c2a, a5c7954e) must still prove to merge to main, measured on 2026-10-22 against main 7a8933b0 and the 2.0.0 release candidate hands/cultlib-unity-release-2.0.0 (5b5d8573, which carries f91e30ed). The land cut is the merge plus the gate. Everything else is classified below. Governing ruling: cultlib-gaps:ruling:release-now-c2a-bumps-format.

Cut sequence to main (2026-10-27, branch head 193cf1c3)

Landed since the facts below: land-c2a (h1), land-c2a-merge-api-docs (h1-h6), the 2.0.1 and cultcache-rs 0.3.1 merges (5baa02fa, f54472e5), land-c2a-gate r4 (h1) and store-gate-one-owner (h1-h7, Soul s6). Restamp-on-write had not started: Registry.Declares is still the ownership answer at 193cf1c3 (CultCache.cs:751, :3866, :4589, :4600). What stands between the branch and main, in order:

  1. restamp-on-write (spec r2, Hands then Soul). One ownership rule, Owns: a record a type reads whole as compatible drift is writable and restamped on write. Fixes the High finding cut-land-c2a-gate.s1.reflist-record-read-only-at-head. Carries follow-up gate-bound-test-tighten.
  2. land-c2a-gate (spec r6, Hands h2 then Soul). The ref-list gate compares ids after a write, vectors pinned byte for byte, every stage counts its cases. Red at 193cf1c3, green after step 1. Closes the three cut-land-c2a-gate.s1 findings and follow-up gate-reads-1x-written-store.
  3. Ledger (Self, no Hands). The seven cut-land-c2a.s1.* findings are still in force though land-c2a-merge-api-docs carried their pins (source reads 2026-10-27: GameCult.Caching grants InternalsVisibleTo only to .Tests and .MessagePack, the residual being follow-up caching-messagepack-ivt; PreCut2StoreFormatTests pins "with a suffix of"). Close each against its commit or defer it. stale-unity-plugin-dlls belongs to the Unity 3.0.0 release cut (the plugins are 2.0.1 builds, 1eeb1a01). Follow-up slot-reuse-contract-doc is done (cultcache-schema-compatibility.md:64).
  4. Consumer pins (operator ruling unpinned-consumers-at-land-2, pin-then-land). Brokkr, Aquarium, Ymir, AquaSynth and Mimir have no CultLibRevision/VerifyCultLibRevision guard and build against the sibling ..\CultLib checkout (grep of their checkouts, 2026-10-27). Each adopts Aetheria's guard at the 2.0.0 tag, in its own repo, before the merge reaches main. No record owns this yet. Gjallar was ruled deprecated like Eve afterwards (gjallar-deprecated-ymir-off-eve), so it likely needs no pin, as Eve needs none; Self confirms.
  5. Main into the branch, then full verification. origin/main 0ad9811f is 4 commits past the merge base 02cf04b5, and they are research docs only (3 files under docs/research, +423), so the merge should be clean. Run the suites listed under "The land cut's shape" on Yggdrasil at the merged head. If main gained code by then, a Soul pass on the merge.
  6. Land. git merge --no-ff hands/variants-c2a onto main, with an empty diff against the verified head. No tag: tags, Unity plugin rebuild and declared-range-gate-own-version belong to the 3.0.0 release cut.

Facts

1. The merge. A trial git merge origin/main on a5c7954e gives 12 conflicted files and 33 hunks of about 950 marker lines (CultCache.cs 17 hunks/196; cult-cache.test.ts 337; single-file-messagepack-backing-store.ts 4/106; rs lib.rs 2/51; VariantTests 51; three CHANGELOG add/adds; store-composition contract 32; Directory store 14; InteropPeer 21). Main moved 4.8k lines in the cache runtimes since base 1a12bc2d. Three semantic collisions are not textual:

  • Durable record identity has two owners. Main added CultStoredDocument.PersistedSchemaId and CacheBackingStore.MarkWritten (73d438db, afd799ba, 3f6dda8b). Conditional commit compares the persisted schema id plus storedAt. C2a already owns this as StoredSchemaId + StoredVersion (SHA-256 of the stored bytes, which names the schema id and storedAt) set by CultCache.Wrote. After the merge, 9 references in ConditionalCommitTests and the contract name main's owner. The version is the owner. PersistedSchemaId and MarkWritten are deleted, and main's +309 test lines are ported as pins against StoredVersion. 2.0.0 ships MarkWritten as protected API, and C2a also removes SerializeSnapshot/DeserializeSnapshot overrides and changes ConditionsHold. C2a is therefore a major release (3.0.0), which semver forces.
  • TS store commit moved. Main's store-file.ts (0e6452c9, 0010ee92; 391 lines) now owns commit, replace and the <path>.lock. C2a's v3 read and keep-v3-on-rewrite (STORE_FORMAT_ELEMENT_IDS) lives in the old store file and must move into store-file.ts.
  • Rust decode door. Main's cultcache-rs 0.3.0 grown decoder (decode_stack, fc630d3b) must sit under C2a's readable_store_format and v3 decode_store_file.

2. The gate already holds; it needs a test, not a format change. C2a stamps cultcache.store.v3 (single file) or ...v5.directory-content-addressed-pages exactly when a record holds an id (HeaderFor, CultCache.cs:3543). It also marks when a copied record decodes holding one (CopiedHoldIds, :4285). The 2.0 C# reader accepts only v1/v2 (CultDocumentMessagePackSerialization.cs:229) and directory v4 (Directory store :452). Main's TS, Rust and Python readers accept only v1 (store-format.ts isV1Snapshot, lib.rs:1274, stores.py:192). Probe on Yggdrasil at 5b5d8573 plus C2a's v3-base.msgpack (job gate1.log): 4/4 pass. The single file is refused naming v3, and a later flush leaves it byte-identical. A v5-stamped directory store is refused naming v5, and its 4 files are byte-identical after a later add and flush. The pin: C2a already ships tests/vectors/document-variants-c2a/old-reader-refusal.sh, which stages an old revision plus probes on a scratch ref and runs them per runtime. The land cut sets its OLD_READER_REV default to the tag cultlib-unity-v2.0.0. It also adds the never-rewritten assertion and the v5 directory case to the C# probe, and runs all four runtimes at the tag. If the tag is absent, the land waits for it. The tag, not a package, is the pin: the tag's tree holds every runtime's released reader.

Outside C2a's reach (release session): in 2.0, CultMesh.WriteSingleFileDocument/ WriteSingleFileDocumentPayload (CultMeshSingleFileDocuments.cs:211-245 at 7a8933b0) build a fresh one-record v1 snapshot and replace the file without reading it. A 2.0 Mesh write to a C2a v3 path therefore discards it whole, ids and other records included, and no header gate can stop a writer that never reads. C2a's version reads under the lock and refuses. Fix it in 2.0.0 before tagging, or record it.

5. Consumers. Aetheria pins a CultLib revision (VerifyCultLibRevision) and Unity tags. Ghostlight, Eve's Rust crates, Huginn and the Brokkr daemon pin cultcache-rs by git rev. None of them moves when main moves. Two C# consumers build against an unpinned sibling ..\CultLib checkout: Eve GameCult.Eve.Surface.csproj:16 and Brokkr's three csprojs. Eve.Surface has [MessagePackObject] element arrays with no [CultElementId] (EveAssetCatalogEntry[], EveEntitySoaBuffer[], EveInputActionDocument[], IReadOnlyList<EveCommandTemplate>, 0 ids in the repo), so C2a registration refuses its documents as soon as the checkout pulls the land. Grep finds no object lists in Brokkr's [CultDocument] files (not build-verified). Removed C2a API (Options, AsSchemaAlias) is unused by Eve and Brokkr. Fork: variants:question:unpinned-consumers-at-land.

Classification

Item Class Why
c2a-format-version-gate a The gate test above. No new header: v3/v5 already refuse.
slot-reuse-contract-doc a The ruled consequence of the write set C2a lands. One paragraph in cultcache-schema-compatibility.md, absent at a5c7954e (grep).
foreign-ids-under-v1-header b Obsoleted by fb065f0d (write-time CopiedHoldIds, after the finding) for every C# writer with the type. The residual (non-C# or opaque writers) is what non-csharp-writers-mark-ids-2 ruled to carry, and the contract states it (persistence-format.md:434-439). The gate changes neither side, so no fork remains. Resolve as Superseded by that ruling.
custom-resolver-element-ids b Fixed by b091d21f (19:52Z, after the 18:27Z follow-up): ListElementsClaimedByAResolverAreRefused, AResolverClaimingTheListTypeOfAnObjectListIsRefused. s6 holds on the claim loop.
codec-door-proven-mismatch b Done in h6 57fdf70c (s6 only-proven-mismatch Holds; Geometry 76/0).
consumer-element-id-sweep-r2 c Each repo's sweep is its own work (q3). Only the unpinned trackers matter at land, and that is the question below. Aetheria bumps a pin on purpose (C4).
schema-discovery-holder-recogniser c TryGetDictionaryRefTypes is identical on main (CultCache.cs:1288). It describes record-ref dictionaries and is not on the id path. Changing it moves schema hashes, which is its own epoch event.
copy-check-memo c Performance only (about 3x flush on unmarked stores of 20k records). Correct as is.
string-key-field-and-shadow-pins c No wrong behaviour at 64ec14f9. These are test pins and may ride the land cut if Hands touches the file.
declared-range-gate-own-version, range-check-in-ci c The script exists only on C2a and runs in no CI. It guards a TS publish, not a merge. Fix both before the first TS release from main after the land.
s6 Unproven: Unity/IL2CPP identity, other MessagePack versions c The merge reaches no Unity build. Unity sees C2a only at the 3.0.0 tag, so measure it in the Unity release cut (operator Unity check, C3).
multi-store-commit-atomicity, store-replace-cut, reader-refuses-duplicate-keys, soft-flush-all-runtimes, writer-dangling-link-tolerance, unique-index-cut, py-shared-write-lock, rust-lock-open-waits, ts-lock-test-hardening, c0/legacy/directory vectors, culture-test, wire-id-cuts, remaining-variant-cuts, golden-path, paged store c None says "before land". Each is pre-existing on main or C2a-independent. None lets a released reader strip ids or corrupt a C2a store.

The land cut's shape (transcribe once the question is ruled)

  • Base. Merge main at or after the cultlib-unity-v2.0.0 tag into hands/variants-c2a. Versions stay main's. CHANGELOGs get Unreleased entries, and the Unity entry says major.
  • Deletes and collapses. Delete PersistedSchemaId, PersistedIdentity, CultStoredDocument.MarkWritten and CacheBackingStore.MarkWritten (main, about 60 lines), and the store-composition contract's MarkWritten paragraph. C2a's StoredVersion/Wrote is the one owner. In TS, delete C2a's header handling in single-file-messagepack-backing-store.ts and move it into store-file.ts. In Rust, C2a's v3 decode goes through the grown decoder.
  • Adds. Retarget old-reader-refusal.sh to the tag. Add the C# probe's never-rewritten assertion and the v5 directory case (as in gate1.log). Add the slot-reuse paragraph. Port main's ConditionalCommitTests to the version identity.
  • Verification. Full suites: Caching, Mesh, Networking, Geometry, WebSockets, ConsumerBuild, InteropPeer, cultcache-ts/-rs/-py, cultnet-ts and cultmesh-ts, all on Yggdrasil. Run old-reader-refusal.sh for all four runtimes at the tag, and it must refuse. Negative greps over the merged tree: PersistedSchemaId|MarkWritten, conflict markers. Mutation: drop the v3/v5 stamp in HeaderFor and the gate probe must fail.
  • Estimate. About 950 conflict lines resolved. Net -60 to -150 from the identity collapse and TS de-duplication, +80 to +120 tests and docs. One Hands pass of 100-150 calls, then one Soul pass.
  • Precondition (question). Eve.Surface, and Brokkr if confirmed, are pinned or swept before the merge reaches main.

Store gate: attachment is the attach's hold (store-gate-one-owner r2)

Spec variants:cut_spec:cut-store-gate-one-owner.r2 (base ec94ab98), question variants:question:pulled-variant-codec. It closes Soul s3's attach-window-cacheless-write (Medium) and codec-loan-pinned-by-reflection (Low).

Facts (source reads at ec94ab98 and a probe, 2026-10-25).

  • The window. AddBackingStore holds the cache's gate, then the store's gate through InStoreHold, lends the registry and codec, sets Loaded, adds the store to _stores, pulls, and only then sets store.Cache (CultCache.cs 2268-2312). InStoreHold lets any call on the same store's gate re-enter it (2825-2831). So a journal the pull's Admit runs on the attaching thread can call store.WriteDocument: Gate(cacheless) guesses null, re-enters the store's gate, finds Cache == null and runs the write, and its finally calls UseRegistry(null, null) (Soul s3 P1, full1.log lines 49-53). Other threads are not in the window: they queue on the store's gate and see either the published cache or a store that the failed attach put back.
  • The codec loan has no reader on the attach path. A pulled variant's codec is read only by CultStoredDocument.HoldsIds (543-557). Header decisions read HoldsIds only on staged entries (single-file 4318, 4447 and 4472; directory store 362), and a staged entry is always the document the cache pushed (Push sets Entries[key] = entry, 4211-4215). A single-file PullAll returns without loading while the store is dirty (4157-4159), so a pulled copy is never staged. A refused write unstages its keys before it adopts the reload. The directory store refuses variants (ruling q6-directory-and-cultnet-refuse; DirectoryMessagePackBackingStore.cs 139-144 and 642-643). Admit re-resolves every pulled variant with the cache's codec (CultCache.cs 3259). Soul's N4 mutant (the attach lends no codec) was caught only by the reflection test, and h4's M2 only by a bare-store test that sets Loaded and calls UseRegistry by hand.
  • Probe (scratch clone imagination-store-gate-r2/clone, commits 4663934e and c099b9e8 on ec94ab98; Yggdrasil t1.log, t2.log and t3.log; 2026-10-25 around 20:50 CEST). The probe did three things. InStoreHold refuses a cacheless call that re-enters the same store's gate. The _codec field and the two-argument UseRegistry are deleted. The pull builds variants with a null codec. Results:
    • Mesh is 342/0/4.
    • In the Caching suite, the only failures were two probe tests, both from authoring errors: a key AddAsync minted, and an element id that was not 12 hex characters. No existing test failed, UserCodeInACachelessCallMayCallItsOwnStore included.
    • Soul's P1 as a test: the journal's write is refused with the new message. The attach keeps its registry and the file holds only a. After the attach, a cache write, a flush and store.PullAll() all succeed.
    • A cacheless write nested in another on the same store is refused, the outer write fails, and no file is written.
    • Through a real attach, a store holding a name-override variant flushes an unrelated commit with a cultcache.store.v2 header. With no codec loan, the pulled copy does not mark the store.
    • Mutant return cacheless changed to return false, which is ec94ab98's behaviour, fails both new gate tests. In that mutant the nested write succeeds and the file exists.

Rationale.

  • Attachment has one fact again. While the attach runs, the store's gate is the attach's hold, and Cache is published when that hold ends. A cacheless write is a write the store owns outright, so it must be the outermost hold of its store's gate. One more argument to the existing owner of per-thread gate facts (InStoreHold) refuses it. No field mirrors Cache, and Loaded and _registry are not read. Precedent: ConcurrentHashMap.computeIfAbsent refuses a mapping function that updates the same map with IllegalStateException("Recursive update") since Java 9. That is from memory, not fetched this pass; the 2026-10-23 prior-art note covers the documented restriction in its counter-positions. Non-cacheless store calls from user code still re-enter: UserCodeInACachelessCallMayCallItsOwnStore stays green.
  • The late publish stays. It is what bounds Gate at two passes (s2's finding). Publishing early would bring back the failure-rate-bounded retry.
  • The codec loan goes: it is a value nothing on the attach path reads. A pulled variant carries no codec and counts as holding ids (the documented conservative answer), and the cache's resolved copy is what any header decision sees. This reverses the codec half of Defaulted ruling late-publish-needs-attach-codec, so it goes to Self as variants:question:pulled-variant-codec. The spec is written for the recommended option.
  • Scar check: git log -S over CultCache.cs for reentrant, outermost and _attaching finds nothing. Cache?.Codec appears in 1468a83a (h4) and 619e089f only. No earlier re-entry refusal was built and deleted.
  • Behaviour change, stated in the CHANGELOG: a cacheless write made from user code that another cacheless write of the same store runs used to succeed, and its finally took the outer call's registry. It is now refused.

Store gate: Cache is the one attachment fact (store-gate-one-owner r3)

Spec variants:cut_spec:cut-store-gate-one-owner.r3 (base 0fa836cb), superseding r2. It closes Soul s4's attach-window-held-store-writes (Medium), cacheless-refusal-order-unpinned (Low) and interop-consumer-suite-unrun (Low). This is the third time the attach window has beaten the rule (s3 from the cacheless side, s4 from the held side), so r3 moves the decision instead of patching the next entry point.

Facts: every entry point that reads or decides attachment, at 0fa836cb. Source reads of CultCache.cs, DirectoryMessagePackBackingStore.cs and CultMeshSingleFileDocuments.cs. The window column is a journal on the attaching thread, through a real attach of a store holding a. Probe: scratch clone imagination-store-gate-r3/clone, commit b2330b6d (test R3Probes), Yggdrasil probe-base.log, 2026-10-25 around 23:00 CEST. "Disagree" means that after the attach, a commit of later and a flush, the attaching cache holds a,later and a fresh cache over the same file reads something else.

Entry point (line) Fact it reads In the attach window at 0fa836cb
Single-file PullAll (4156) Gate: Cache guessed unlocked, then re-read under the re-entered store gate Runs as detached; its Loaded re-enters the cache and is refused as a journal write. Agrees.
Single-file Push (4212) same Runs as detached and stages b. Disagree: fresh reads a,b,later.
Single-file Delete (4223) same Runs as detached and stages a removal the cache never made (dirty after the attach).
Single-file PushAll (4239), and Dispose (3722) through it same, then in-body Cache == null (4531, 4543, 4588, 4603, 4611, 4661) Runs as a cacheless writer. Push then PushAll writes b to the file inside the attach (disagree). Delete then PushAll is refused with the cacheless writer's text. On a variant store it is refused as "a writer with no CultCache" (Soul s4).
Single-file CommitBatch (4256) same Runs as detached and commits b. Disagree.
Single-file JudgeChecks (4568), RefuseRemovalOfUndeclared and StageChecks (4558, 3915) same Run as detached. Agree in the probe, but under the wrong gate's rules.
Single-file ReadStoreSnapshot (4687) same Runs as detached and reads the file.
WriteRecord (4342), WriteDocument (4366) InStoreHold's per-thread hold (cacheless re-entry), before Cache Refused with the re-entry text (h5). Agree.
Directory PullAll (48) Gate, as above Refused as a journal write. Agree.
Directory Push (147), Delete (160), CommitBatch (172) same Run as detached. Disagree: fresh reads a,b,later after Push or CommitBatch, and later after Delete.
Directory PushAll (235) same Runs as detached. Push then PushAll and Delete then PushAll disagree.
Directory JudgeChecks (269), RefuseRemovalOfUndeclared (259) same Run as detached. Agree.
In-body reads: StoredAtOver clock (4028), refusal text and DurableAt (4531, 4543), declares and cacheless refusal (4588, 4603, 4611), Wrote (4661) Cache, under the gate Gate chose On the attaching thread they read null while the thread holds the cache's gate, so a held call runs as a cacheless writer.
AddBackingStore (2259-2314) writes Cache under both gates, after the pull The window's author: the store is attached for the pull (registry lent, Loaded set, in _stores) while Cache is null.
Mesh CultMeshSingleFileDocuments writes and reads (99, 143, 174, 226) none of the attached store's StoreAt (239) builds a new store per call, so they never touch an attached store object. A file it shares with an attached store is the file lock's and the write set's business.
IsDirty, HasStagedChecks, ForeignRecords, LastSchemaMigrationReports, HeaderFor, HeaderForWrite none Ungated values or static decisions. They read no attachment fact.

So during an attach, cacheless entry points decide attachment from the hold, and every held entry point, plus every in-body read, decides it from Cache, which is still null. These are two facts.

Probe of the chosen shape (commit 4695390c on b2330b6d; proto-full.log). AddBackingStore publishes Cache before the pull and takes it back in the same hold if the pull fails. Gate becomes straight-line code: guess, then the store's gate, where Cache is settled, then the settled cache's gate. Results:

  • All 21 probe cases are refused. The cacheless writes get the re-entry text and every other case gets "A journal entered the cache it journals". After the flush, the attaching cache and a fresh cache both read a,later.
  • Caching: the only failures are the two tests that pin the late publish: AnAttachLendsTheRegistryAndPublishesTheCacheAfterThePull (asserts no cache during the pull) and ACallWhoseGateChangesTwiceUnderItThrowsInsteadOfLooping (asserts the old message text; the tampering reader still throws). The churn, queued-reader and failed-attach tests pass unchanged.
  • Mesh and Networking are green.
  • The cultcache-ts suite, which builds and drives the C# InteropPeer, passes on this shape: 175 tests, 171 pass, 0 fail, 4 skipped. The C#, Rust and Python interop tests ran, the C# edit-in-place one included (proto-ts2.log, commit f5f02053). Recipe clone/r3ts.sh: image node:24-bookworm, apt python3-msgpack, dotnet-install for 10.0, rustup minimal.

Shapes weighed (rationale).

  • (a) Publish Cache before the pull, as a616431e did. Chosen. What s2 found was not that an early publish is unbounded. It was that Gate re-read Cache unlocked after a stale guess, so a churn of failing attaches could hand it a new stale guess every pass. The fix is where Gate reads on its second step. Under the store's gate, Cache is settled: an attach publishes and takes it back inside one hold of that gate, and no other thread can hold that gate during the attach. A settled cache is permanent, because there is no detach. So Gate needs at most three acquisitions, with no loop and no counter: the guess's gate, the store's gate, and then the settled cache's gate, which cannot miss. This deletes a fact rather than reconciling two. During the attach the store is attached, so every entry point, cacheless or held, and every in-body read sees one answer. A journal's call is then refused by the rule journals already have (AddJournal: "any hold on this cache while a journal runs throws"), exactly as it is after the attach. Prior art: ConcurrentHashMap.computeIfAbsent installs a ReservationNode in the bin before running the mapping function, so a recursive update on the same thread finds the owner and throws, and on failure the reservation is removed (see docs/research/2026-10-23-store-lock-user-code-prior-art.md item 4 on main: the re-check after locking, and the ReservationNode on the empty-bin path). The validate-after-acquire in Gate is the same item.
  • (b) Keep the late publish and make the attach's hold the one fact for every entry point. Rejected. InStoreHold knows that this thread holds the store's gate, not whose hold it is. Refusing every same-store re-entry kills UserCodeInACachelessCallMayCallItsOwnStore (r2's R5), so (b) needs an "attaching" marker beside Cache. That is the second fact that s3 and s4 rule out, plus every in-body Cache == null read would need to consult it as well.
  • (c) Run no user code on the attaching thread under the store's hold during a pull: defer the pull's journals to after the hold (open calls). Rejected as insufficient on its own. Getters and constructors still run during the pull's decode, so the window stays open to any user code that is not a journal. It also moves journal timing, which is a documented contract ("right after an admission is applied").
  • Scar check: git log -S over CultCache.cs finds store.Cache = null and MaxStaleGuesses only in ba658965 and 1468a83a. The early publish was not removed for a correctness reason of its own: 1468a83a removed it to bound Gate and to lend the codec, and the codec loan is gone (pulled-variant-codec). No settled read under the store's gate was built before. The precedent recipe (no root filter) finds no same decision.
  • Not a product fork. A journal's store call during an attach now throws, as it already does after the attach. Cacheless writes on the attaching thread stay refused, with the same text. Getters and constructors during the pull see an attached store, as they do during any held call. It changes the late-publish clause of Defaulted ruling variants:ruling:pulled-variant-codec, so Self defaults a superseding ruling (recommended label cache-published-for-the-pull) before dispatch.
  • Residual, not in r3. Push, Delete, PushAll and CommitBatch are public, so user code that is not a journal (another thread, or a getter during any held call) can stage records behind an attached store's cache. Soul s4 shows this with other-thread-push: file a,b, cache a. That is true before, during and after an attach, so it is not the window's two-fact problem. Closing it changes public API (a store's writes taken only from its cache), so it is a follow-up and an operator question for Self, not part of this cut.