| A. v1 header, record gains slot 4, payload empty | refuses, "read past end of stream" | refuses, "incomplete input" | refuses, "Offset outside DataView" | refuses (strict tuple), misleading legacy-envelope message |
| B. v1, slot 4 plus a full payload | **loads the payload silently** | **loads silently** | **loads silently** | refuses |
| C. header `cultcache.store.v2` | **ignores the header**; refused only by the empty payload | refuses, misleading "legacy envelopes" message | refuses | refuses |
| D. payload holds the delta as a sparse array | **loads `Name=''`, `Max=99`** | **loads `name=None`** | refuses | loads; `get` of that key fails |
Consequences:
- The **empty payload is the only tripwire every shipped reader trips**, pinned builds
included (Aetheria pins `caching-unity-v1.4.0`). A variant record's payload must be empty,
and the delta must live outside it.
- A payload-as-delta encoding is ruled out: C# and Python misread it silently.
- C# never checks `formatVersion` (`CultDocumentMessagePackSerialization.DeserializeSnapshot`
defaults a missing one to v1). C#, Python and TypeScript skip extra record slots silently.
These are latent defects today and become misreads when the format grows.
- Every refusal is loud but none of them names the record. The target requires named refusals,
so current readers need a small fix (C0).
Other Body facts that shape the map:
- **CultNet has no member-path grammar.** `docs/cultnet-selection-cut.md` D2: "Declared
aliases, not a field-path language". It reaches top-level declared `index`/`role` aliases
only (`CultDocumentMemberView`). Nothing exists to reuse. See section 3.
- **The schema catalog describes top-level members only.** A nested type (`BehaviorData`,
`PerformanceStat`, `HardpointData`) appears only as a CLR type name. Soft drift and hard
rejection cannot see below slot level.
- **Resolution needs a codec, and the core has none.** `GameCult.Caching` holds objects. The
MessagePack codec lives in `GameCult.Caching.MessagePack`. `CultInspectorModel` already
takes the codec as two injected functions (`CultCacheMessagePack.CreateInspectorModel`).
- **Same concrete type implies same home store.** `Home(type)` routes by type, so ruling 3
("same store") falls out of ruling 6. No separate check is needed.
- **The name index shadows silently.** `Index()` does `MapOf(_names, type)[name] = key`, so a
second record with the same name overwrites the entry. A variant that inherits its base's
name would make `GetByName` return one of the two, depending on apply order.
- **Studio edits by whole-document upsert.** `CultInspectorEdit.Commit` calls
`UpsertAsync(type, copy, key)`, and `Duplicate` clones through the codec.
- **Raw readers bypass the cache.** In CultLib: `CultMesh.ReadSingleFileDocument`
(`CultMeshSingleFileDocuments.cs:112`), plus its legacy snapshot decoder. In consumers:
| Variant record | its own `CultRecordKey`, minted like any record; same `schemaId` as its type | created, re-overridden, rebased, removed; never converted to or from a plain record implicitly (Q1) | author through Studio or the variant write API; the cache validates |
| Base reference | the base's key, in the record's variant slot | changed only by an explicit rebase; a base cannot be deleted while it has variants, unless the same batch rebases or removes them | author; the cache refuses a cycle, a missing base, a different concrete type, a global type |
| Override entry | `(op, path, id, value)`; op is Set, Insert or Remove | added by editing; removed by clearing; survives base edits while its path resolves | author; the cache refuses entries a base edit strands (Q2); an entry whose member left the type soft-drifts with a warning (Q4 c) |
| Member path | a sequence of `(slot, elementId)` steps; `slot` is the MessagePack `[Key]`; `elementId` is `""` unless the step enters an object-list element | a type change can invalidate it | the type's owner (slot authority); ids by the element's creator |
| Element id | a `[CultElementId]` string member on the element type of an object list in a shape the walk owns (arrays, `IEnumerable<T>`, `IDictionary`/`IReadOnlyDictionary` values, `[MessagePackObject]`/`[Union]` types with integer keys), one MessagePack writes and reads back (integer `[Key]`, no `[IgnoreMember]`; public, or any visibility under the most derived `AllowPrivate`); `""` means unset. **Exception (q3 amended 2026-10-16):** a shape the walk does not own is one whole value: `ImmutableArray`, `ArraySegment`, `Memory`, `KeyValuePair`, tuples, `Lazy`, unkeyed or `[DataContract]` classes, string-keyed types, a type or member with `[MessagePackFormatter]`, and a non-public member without `AllowPrivate`. Its elements carry no ids: a `[CultElementId]` inside is a plain string | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element. Inside a whole value: never minted, never checked, never counted for `v3` | cache mints it; it is never retargeted. `CultElementIds.Classify` alone decides owned versus whole |
| Resolved view | `CultStoredDocument.Document` for a variant key | recomputed inside the hold that admits the variant, or any base on its chain | the cache only; never persisted, never written back (Q1) |
| Index and name entries | as today, from the resolved view | a dependent's entries are dropped and re-added in the same `Apply` as its base change | the cache; a variant must override the `[CultName]` member (rule R6) |
| Watches | as today; each re-resolved dependent emits `Updated` with its own `Sequence` | published in the same hold as the base change | the cache |
| Compare-exchange | `(schemaId, storedAt)` of the stored delta (ruling 2) | a base change does not stamp its variants | the cache; the resolved view is not an identity |
| Persisted format version | store header `formatVersion` | `cultcache.store.v2` when the store holds at least one variant; `v1` otherwise (Q5); a marked `v3` (`v5` directory) stays while a write copies any record forward, and only a write that encodes every record drops it by content | the writing store, from content; the durable header alone says the bytes hold element ids |
| Single-file store file | the path; one MessagePack array `[header, catalog, records]` | replaced whole and atomically; refused when unreadable; see "Store authorities" | each runtime's one reader decides what is a store; the writer derives the header from it |
| Store lock and staging files | `<file>.lock`; a unique temp beside the store | the lock is taken by C# and Rust writers (CultMesh included, through `ReplaceDurable`); staging is removed on a failed write | the store, never CultMesh or a consumer |
Element-id part 2: `c2a-id-closed-world` r2 decides deferred findings `s5.member-set-misses-allowprivate`, `s5.enumerating-rule-unpinned`, `s6.null-standard-untyped`, `s6.two-recognisers-one-rule`, `s4.unrecognised-holders-fail-open` and follow-up `c2a-datacontract-holder-unwalked` (see its `first`).
- **marker-for-whole-lists** (only if `c2a-whole-list-opt-in` is ruled `marker-when-ids-declared` or `marker-for-any-keyed-object`): a CarriesElements predicate, a refusal unless the member is marked, and a public attribute. It is additive after part 2.
- **C2b constraint** (owed to `variants:follow_up:remaining-variant-cuts`): nested path resolution must refuse a step `[slot:elementId]` that enters a whole value, naming the holder. A whole value is any shape `CultElementIds.Classify` calls Leaf, so C2b asks Classify and keeps no recogniser of its own. Today's C2a resolver refuses every non-top-level step, so no store can hold such an override yet.
- Add `[CultElementId]` to `BehaviorData` (base) and `HardpointData`.
- Run `MintElementIds` once over `GameData/Aetheria.cc`.
- AetherDb migrations skip variants or use the variant API (Q1a).
- Re-author the families listed in section 6.
### C5. CultNet deltas (C# reference)
Adds a new raw-record message version that carries the variant slot, following selection D3:
a v1 message string, so peers not in this cut refuse through their own dispatch. The C1
refusal in `CultNetDocumentRegistry` is deleted. Scheduled when a CultNet consumer needs
variants; none does today.
### Parked: TypeScript, Python and Rust resolution
Each runtime gets its own cut when a consumer that reads a variant-bearing store exists
(ruling 5). None does. `Aetheria.cc` is read by C# only.
## 6. First payoff in `Aetheria.cc`
Scan of the live file (206 records, `v1`): records of one schema whose leaves differ by at
most 20 percent, name excluded. This is a heuristic; the operator confirms the real families.
| Type | Records | Families that collapse | Records becoming variants |
| --- | --- | --- | --- |
| geardata | 32 | Refinery / Assembly Line / Deep Ore / Surface Ore / Shipyard (1 to 3 leaves differ); Small / Medium / Large Drive; RevvITup 2.0 / deep space burnout / Talaria; Skiron / Iapyx; Arctica / cold like my heart | 10 |
Probed at CultLib `hands/variants-c2a` 92d9e138. The probe ran on Yggdrasil in `ack1d-interop:2` from a scratch branch, which has since been deleted. Sources: `scratchpad/schema-identity-probe/`. Full output: `scratchpad/probe2.log` (store round trip, section 1), plus a wire rerun shown inline below. Every probe used one declared schema: the interop note, name `cultcache.interop-note`, version `cultcache.interop_note.v1`, with six members.
### 1. How each runtime derives a schema id (probed)
| Runtime | Id it stamps | Derived from | Carries name / version / content hash? |
|---|---|---|---|
| C# | `sha256:eafe3e…b249` | `sha256("name\|version\|slot:Member:ClrType:value/ref:target:one/many\|…")`. Recomputing it by hand gives the same id. | Store catalog: name, version, `contentHash = sha256(canonical JSON)`, canonical JSON, members. Wire: `schemaName`, `schemaVersion`, `schemaContentHash` are all filled. |
| TS | `cultcache.interop-note` | `definition.schemaId ?? schemaName ?? type`. There is no hash. With no declared version, the version defaults to `<name>.v1`. | contentHash is whatever the author declares. The interop test declares the id string itself. |
| Python | `cultcache.interop-note` | `schema_id or type`. There is no hash. contentHash defaults to the id. | Same as TS. |
| Rust | `cultcache.interop-note` | `#[cultcache(type=…)]`. There is no hash. `schema = "CultCacheInteropNote"` never reaches the catalog. | The catalog entry it writes is fabricated: version `"<type>.v1"` (`cultcache.interop-note.v1`, not the declared `…_note.v1`), `members: []`, `contentHash = type`. |
| Kotlin (CultMesh only; it has no store) | `cultcache.interop_note.v1` | `codec.schemaVersion` | Nothing else. |
On the C# fingerprint:
- **The id stays the same** when only the CLR class or namespace changes (V1).
- **The id changes** when a member is renamed (V2), a slot is appended (V3), `string[]` becomes `List<string>` (V4), the version string changes (V5), or a slot is removed (V6).
- **V2 and V4 produce identical wire bytes.** MessagePack slot arrays carry neither member names nor CLR collection types. So the fingerprint witnesses the C# declaration, not the wire layout.
The C# registry already refuses two CLR types for one `(name, version)` in a process. The probe crashed on exactly that when V6 and the base type shared an assembly. Inside C#, `(name, version)` is already the ownership key, and the hash adds nothing to ownership.
### 2. Wire parity for identity does not hold (probed)
No two runtimes agree, and C# is the only one that hashes. The CultNet interop harness is the parity witness in CI. It never exercises the C# default: `tests/GameCult.Networking.InteropPeer/Program.cs:77,201,1183-1196` binds every type with `ForDocument(schemaId: <readable JSON-schema URL>)`.
What reads what today:
- **Stores (`.cc`). C#, Rust, TS and Python all read each other's notes.** It works only because each reader, when the id is unknown, falls back to the embedded catalog's **schemaName**: C# `CultCache.cs:653``BySchemaName`, TS `cult-cache.ts:827`, Rust `resolve_registered_type``lib.rs:2754`. Round trip, C# → X → C#:
- Each writer stamps its own record with its own id.
- **A C# rewrite restamps every other runtime's record to `sha256:…`** and drops their catalog entries.
- **A Rust write destroys the C# catalog entry for the id it read.** The entry becomes name=`cultcache.interop-note`, version=`sha256:…eafe….v1`, contentHash=`sha256:…eafe…`, canonical JSON with `schemaName: "sha256:…"`, `members: []`. The C# record bytes survive, but their description is lost. That breaks F2 ("never relabels…") at the catalog level, and it is a defect whatever this fork decides.
- TS and Python kept the C# catalog entry intact.
- **Wire (CultNet raw record carrying a C# `sha256` id):**
| Kotlin `CultCache.putRaw` (probed) | refused | ok (documentType) | ok |
| C# `CultNetDocumentRegistry.DeserializeRawDocument` (probed) | ok | ok | ok, **and `gamecult.unknown.v1` → `Note` too** |
| Python `cultnet_py``resolve_document_for_raw_record` (read only) | only if declared | ok | ok, plus payload sniffing |
| Rust `pull_rudp_catalog_snapshot` (read only, per the Eyes sweep) | dropped silently | — | strips `.vN` and matches the type |
C# accepts any id because `TryResolveDescriptorBySchemaAlias` and `TryResolveDescriptorByPayloadSchema` (`CultNetDocumentRegistry.cs:755-830`) read slot 0 of the payload as a schema version. Python does the same (`replication.py:160-196`). **Shape routing and aliasing are still live in C# and Python CultNet on c2a**, and so is C# request matching `CultNetSchemaAliasMatching` (`CultNetDatabase.cs:52-89`). F7 has not reached them.
- **The C# wire id is readable only when the binding is overridden.** The probe showed `ForDocument<Note>(schemaId: "cultcache.interop-note")` emits that id with the same payload, name, version and contentHash. `CultMesh.CreateCultNetDocumentRegistry` (`src/GameCult.Mesh/CultMeshDocumentRegistries.cs:69`) never overrides it, so every C# CultMesh host publishes `sha256` ids.
### 3. Live cross-runtime paths that depend on the fallback
This section comes from the Eyes sweep. I spot-checked the Hermodr lines, the Odin rule and `CultMeshDocumentRegistries.cs`; the rest I only read. Every one of these paths is **TS reader → C# host (the Aetheria daemon in `F:\Projects\AetheriaEve`)**.
| # | Consumer | Asks for | Works via | Under C1 |
|---|---|---|---|---|
| 1 | `EveElectron/src/cultmesh-provider-client.mjs:129-143`, started by `AetheriaEve/Aetheria.Rts.Web/Electron/main.ts:65` | `gamecult.eve.provider_advertisement.v1`, `…surface.v1`, `gamecult.fields.*.v1`, `…command_receipt.v1`, `gamecult.cultmesh.cdn.asset_blob.v1` | cultmesh-ts HELD `firstRecordAtKey` | Exact match. The exception is `cdn.asset_blob.v1`: **no writer anywhere declares it**, so that read needs a declaration. |
| 1b | same file, lines 94-108 (command put) | `gamecult.eve.command_invocation.v1` | C# inbound alias (g), probably | Exact match |
| 2 | `AetheriaEve/Aetheria.Rts.Web/Electron/aetheria-cultmesh.ts:138-147, 345-406` (only the verify-stage7* scripts use it) | `gamecult.aetheria.*.v1`, `gamecult.fields.*.v1` | first-record fallback | Exact match |
| 3 | `Hermodr/src/hermodr-daemon.cjs:1335-1363` | provider state, surfaces, CDN | Its own `|| candidates[0]` / `|| documents[0]`, plus C# request alias (f) | Exact match. Delete both fallbacks. |
| 4 | `AetheriaEve/scripts/aetheria-browser-provider-witness.ts:49-60` (a witness, not product code) | eve schemas | cultmesh-browser `recordMatchesSchema` name/version arms | Exact match |
| 5 | `aetheria-cultmesh.ts:408-411`, local `.cc` read | same ids | none: probably already broken (unverified) | still a store-path question |
**Paths that already work without a fallback, because everyone hand-stamps `<name>.vN`:**
- The Aetheria daemon and Gjallar hand-stamp `.v1` puts to Odin: `Program.cs:4574-4830` and `Gjallar/Program.cs:3040-3080`.
- Odin **refuses any id without `.vN`**: `Odin/crates/odin-daemon/src/main.rs:1310-1326`.
- Rust hosts bind `.vN` through `cultmesh_documents!`: Ghostlight, Epiphany, Muninn.
- Kotlin uses its schemaVersion.
- The Eve C# declaration itself reads `[CultDocument("gamecult.eve.provider_advertisement", SchemaId)]` with `const SchemaId = "gamecult.eve.provider_advertisement.v1"`. Authors already treat the version string as the id.
Scope flag: every broken consumer lives in `AetheriaEve`, which the CultCache campaign ruled "taxidermy". The operator should confirm those paths are live consumers of this campaign before any cut is shaped for them.
Stored data:
-`Aetheria/GameData/Aetheria.cc`, `run.cc` and `player.cc` hold 21 C# schemas under `sha256` ids. The Eyes sweep found only C# readers.
- The versions there are the bare string `"1"`: `[CultDocument("aetheria.faction", "1")]`. So a version string is not globally unique across the estate.
- TS stores (Bifrost, VoidBot, weksa, Stonks) are TS-only and hold readable ids.
- Odin's Rust store holds `.vN` ids.
### 4. What the C# fingerprint protects
A C#-to-C# store read. A record whose declaration drifted misses the exact id and falls to the persisted-catalog comparison: `CompatibleDrift`, or refused as incompatible (`CultCache.cs:900-936`).
Across runtimes it protects nothing today:
- The other runtimes do not compute it.
- Their content hashes are placeholder strings.
- The C# wire receiver accepts any id through payload sniffing (probed).
- The hash reacts to renames and CLR collection spellings, which do not change wire bytes (probed V2, V4).
### Fork F-ID: what names a schema across runtimes
**(A) Every runtime computes the C# semantic fingerprint; readers name schemas by definition.**
- Every non-C# runtime has to emit C# type spellings: `System.String[]` versus `System.Collections.Generic.List<System.String>`.
- Rust has to start declaring members and versions at all.
- String-named readers can no longer name a schema. Eve Electron and Hermodr read ids from provider advertisements.
- Odin refuses the ids outright (`.vN` rule).
- Every TS, Python and Rust store holds readable ids. Under no-relabel those records become permanently held unless each runtime lists its old ids.
- Gain: a layout witness that is still over-sensitive (V2 and V4 change the id with no wire change).
- Stored data: Aetheria is untouched; every non-C# store is orphaned.
- Not recommended.
**(B) Readers match the declared `(schemaName, schemaVersion)` pair the envelope carries.**
- The C# wire already carries both. Store catalogs carry both.
- Two identity strings stay in play: the id for storage, the pair for lookup. That is a split owner.
-`sha256` ids still reach Odin, which refuses them.
- The version alone is ambiguous (Aetheria's `"1"`), so every reader API has to take a pair instead of the one string readers pass today.
- Rust writes a fabricated version, so pair matching against a Rust host fails until Rust declares versions.
- Layout protection is only as good as a contentHash comparison, and no runtime but C# computes a real one.
- Stored data: unchanged, and nothing is relabelled. Resolution becomes pair matching against the catalog, which is a formalised form of today's schemaName fallback.
**(C) C# puts the readable id on the wire, and the id is the declared version string `<name>.v<N>`.**
This is what Odin enforces, Rust and Kotlin use, and Eve and Gjallar already hand-stamp.
- C1, wire only:
-`ForDocument` and `CultMesh.CreateCultNetDocumentRegistry` default the binding id to `descriptor.SchemaVersion`.
- Registering a wire binding whose version lacks `.vN` is refused. Aetheria's `"1"` types are not on the wire today.
- Then delete the HELD first-record fallback (cultmesh-ts), Hermodr's two fallbacks, the name and version arms of cultmesh-browser `recordMatchesSchema`, C# `TryResolveDescriptorBySchemaAlias`, payload sniffing and `CultNetSchemaAliasMatching`, and the Python CultNet alias map and sniffing. That finishes F7 on the wire.
- C2, the store as well, as a later cut: C# stamps new writes with the version id and resolves an old `sha256` record by the type's own **computed** former fingerprint. That is a derivation, not an alias. Where a version string changes (Aetheria's `"1"` → `aetheria.faction.v1`), the old id goes into the existing `CompatibleSchemaIds`.
- The fingerprint is demoted to `schemaContentHash`, which the wire already carries. It stays a C#-to-C# drift witness and does not decide identity.
- Costs:
- C#: the default in two files, plus deleting the alias and sniffing paths.
- TS, Hermodr and Python: deletions only.
- Rust and Kotlin: none.
- Mixed-version C# peers: an old host sends `sha256` and a new receiver without sniffing refuses it, so C# peers upgrade together.
- Eve Electron `cdn.asset_blob.v1`: needs a declaring writer.
- Stored data: C1 changes nothing on disk. Under C2, Aetheria's catalogs are read unchanged and records are restamped only when C# rewrites records it understands, which F2 allows. TS, Rust and Odin stores are untouched.
**Recommendation: C1 now; decide C2 separately.** C1 is the only option where one string names a schema on every runtime and at Odin, where every reader already asks for that string, and where the work is almost entirely deletion. It makes the F7 retirement possible on the wire, because readers no longer need a fallback to find their records. Aetheria's `.cc` files are untouched. B keeps the split and A breaks every readable-id store. Separately from F-ID, the Rust catalog-destruction defect (§2) and the live C# and Python CultNet aliasing should be admitted as findings or follow-ups whatever is ruled.
**Sub-questions for the operator:**
- F-ID.1: Is the id the version string (`<name>.vN`, as Odin, Rust, Kotlin and Eve do), or `schemaName`, which CultLib's own TS and Python tests use and which is also TS's default?
- F-ID.2: Are the AetheriaEve consumers live for this campaign?
- F-ID.3: Should C2 (store identity) be mapped now, or deferred until a non-C# reader of a C# store exists?
### Probed versus read
- **Probed:**
- All C# ids and variants, and the hand-recomputed fingerprint.
- The TS and Python default ids.
- Rust's id and catalog entry, from store bytes.
- Kotlin's id and its accept/refuse behaviour.
- Store round trips C#→Rust/Python/TS→C#, with bytes, including the C# restamp and the Rust catalog destruction.
- C# wire record bytes, default and overridden.
- C# CultNet accepting any id.
- TS CultNet exact-id behaviour.
- **Read only:**
- Python CultNet aliasing and sniffing, and Rust `pull_rudp_catalog_snapshot` dropping unknown records.
- C# request alias matching (f).
- Every consumer path in §3, from the Eyes sweep. I spot-checked Hermodr 1335-1363, Odin 1310-1326, `CultMeshDocumentRegistries.cs:69` and the Eve declaration.
- Path 1b/2b's dependence on (g).
- Path 5 being broken.
- The Aetheria `.cc` catalogs were decoded from the files, with no git.
Verdict: **hold, not merged.** Recorded under drain mode; no fix dispatched.
- **Held:** the Stonks duplicate is fixed; the suites on a merge with main are green (cultcache-ts 54/54 with four-runtime interop, cultnet-ts 198/198, cultmesh-ts 128 passing).
- **F1 (CONFIRMED, high):** `isSameRecord` (`single-file-messagepack-backing-store.ts:284-286`) still matches on the stored label. When one definition's type equals another's schemaName, a put or delete for one destroys the other's record at the same key, globals included. The branch's own test "an envelope with no catalog entry replaces a record stored under another schema id that carries its label" pins this hole. The coherent cut: the cache resolver hands the store the persisted identity (key plus schemaId), and the store compares only that.
- **F2 (CONFIRMED, high):** Python (`stores.py:54-67, 105-119`) has the same duplicate bug. A second `put_global` makes the store unloadable.
- **F3 (CONFIRMED, medium):** Rust `entry_id` is `(type, key)`, which duplicates records written by another runtime. Rust also writes its TYPE into the catalog schemaName slot, a parity defect.
- **F4:** mutant MA (legacy global labelled by schema name) survives; the rule needs a committed test.
- **F5 (PLAUSIBLE, high after c2a):** c2a's compatible-owned ids plus clause 1 let a non-owner's write delete the owner's record. c2a's `encodeSnapshot``${type}::${key}` is a second identity authority.
- Probes survive as unreachable commit `1c7744e2` (ts, rs, py); recover them before gc.
## Soul: C2a F2 keep unknown records (`hands/variants-c2a` `92d9e138..a771e71b`, 2026-10-01)
Verdict: **hold, not merged.** Recorded under drain mode; no fix dispatched. The core promise holds: a C# write neither destroys nor relabels a record it doesn't claim. Checked on the real `Aetheria.cc` (206 records): 0 record or entry differences after a commit and a flush.
- **F1 (CONFIRMED, medium):** C# decodes and re-encodes laid-back catalog entries (`CultDocumentMessagePackSerialization.cs:501,567`, `CultCache.cs:3847-3851`). Unknown entry and member fields are dropped, and nil and int widths are normalized. Fix: carry the raw entry bytes.
- **F2 (CONFIRMED, medium):** laying back a loaded foreign record reverts a newer write by its owner and moves storedAt backwards (`CultCache.cs:3831-3836`, breaks F6). Fix: lay back only while the file still holds the loaded `(id, storedAt)`.
- **F3 (CONFIRMED, medium):** one refused flush blocks every later write to other records. The directory store stays stuck until restart (`CultCache.cs:3827-3831`, `DirectoryMessagePackBackingStore.cs:249-253`). Breaks "writes to other records proceed".
- **F4 (low):** a laid-back record marks the v3 header without holding ids (`CultCache.cs:3731`).
- **F5 (plausible, low):** an in-place change to a laid-back document is dropped silently.
- **F6:** mutants H2 (`CultCache.cs:3850`) and R2 (`lib.rs:2663`) survive.
- **The main merge is its own Hands cut.**
- 8 files conflict, including about 74 KB in `lib.rs`.
- Main refuses a zero-byte store; c2a reads it as an empty v1 store.
- Main's `CultMeshStreamingTests.cs:919` uses `MeshNoteAliasDocument`, which c2a deleted.
- The rough merge fails in Caching, Mesh (compile), TS and Python.
-`ADeterministicIdIsTheSameUnderEveryCulture…` fails at the base too: the image is globalization-invariant.
### A. Header decision (what the file is, and what header a write carries)
- **Owner.** Read side: the reader in the table above. C#:`CSM.DeserializeSnapshot` :188 (whole-array proof :245-269), then `RequireSingleFileFormat` :273, reached through `SingleFileMessagePackBackingStore.DeserializeSnapshot` CSM:592. Rust: `store_header` RS:389 is the only classifier of leading bytes (it returns `first` and the whole-array proof separately), then `readable_store_format` RS:2890 and `decode_store_file` RS:2901. TS: `decodeStoreFile` TS:423. Python: `_decode_snapshot` PY:296. Write side: the header is derived from what the reader just saw. C#:`HeaderFor` CS:3439, fed by `disk?.FormatVersion` in `PushAll` and `CommitBatchCore`, and by `existing?.FormatVersion` from MESH:247. Rust: `encode_store_snapshot` RS:2793, fed by the `DiskStore` read in `write_all_unlocked` RS:992. TS: `#format`, set by the pre-write read (TS:48-52, :86-100) and used by `encodeSnapshot` TS:136. Python: `_format`, set in `pull_all`, used by `_encode_snapshot` PY:245.
- **Inputs.** The bytes at the path. **Outputs.** The decoded store with header and catalog; or a typed refusal (`CultStoreUnreadableException` in C#; `CultCacheStoreUnreadable{path, kind}` in Rust, kind `Undecodable` or `UnsupportedFormat`; `StoreUnreadableError` in TS and Python). A refusal names a header only as `cultcache.store.v<digits>`.
- **Derived state.** The write header is never decided by the writer: it is the reader's verdict (header read, whole-store flag, whether ids or variants are held). A rewrite of a file that cannot be read cannot happen, because the reader throws first.
- **Forbidden writers.** A second header classifier. Deleted: Rust `leading_header`, `read_array_header`, `read_string`; cultnet-ts's own decode of the store header; the TS async `readStore`; Rust `StoreUnreadableError` (negative grep `StoreUnreadableError` in `packages/cultcache-rs`: no match); Python `_decode_v1_snapshot`.
- **Shared paths.** cultnet-ts reaches the header decision only through `readSingleFileStore` (AUTH:4, :321) and checks only record count, schema id and type. cultnet-ts therefore needs a cultcache-ts release that exports it, which is the release that ships with this change; the manifests move to that version then (c0-ships-with-c2a), not before.
- **Pinned by.** `tests/vectors/document-variants-c2a/readability/manifest.txt`, walked by `StoreReadabilityTests.OpenReadsExactlyTheFilesTheVectorsSayItReads` (C#), `CultMeshSingleFileReadabilityTests.AWriteReplacesAFileExactlyWhenItReads` (Mesh), `a_file_is_replaced_exactly_when_it_reads` (RS:5132), "SingleFileMessagePackBackingStore replaces a file exactly when it reads" (TS test :2321) and `test_single_file_replaces_a_file_exactly_when_it_reads` (Python). AUTH cases: `packages/cultnet-ts/test/idunn-runtime-authority.test.ts`. Declared divergences in the manifest: `legacy-envelopes.bin` (C# refuses; the others read and replace it) and `variant-slot-v3.bin` (C# reads; the others refuse).
- **Known gaps.** `variants:follow_up:c0-bad-canonical-in-manifest` (`current-catalog-bad-canonical.msgpack` is not in the manifest; TS reads it, Rust refuses). `variants:follow_up:legacy-vectors-all-runtimes` (the three `legacy-catalog-*` c0 vectors have a verdict only in C# tests; all four runtimes refuse them, probed). `variants:follow_up:range-check-in-ci` (`npm run test:pack:ts` builds every admitted tag and checks exports, but `.github/workflows/publish-packages.yml` does not run it).
### B. Existence and links (what "nothing at the path" means)
- **Owner.** C#:`ReadDisk` CS:4026 decides absence from the path's attributes (`-1`), with `PathIsALink` CS:4057. Rust: `is_absent` RS:3049, inside `read_store_unlocked` RS:936-945. TS: `readStoreBytes` TS:174 (the read and the lstat must both find nothing; the lstat is a test seam). Python: `_read_store` PY:265.
- **Inputs.** The path's attributes and the read result. **Outputs.** Absent (an empty, unmarked store), or bytes, or an I/O error. Only nothing at the path is absent. A link whose target is gone is an I/O error in all four runtimes (C#:`IOException` for a file link and for a Windows directory junction, CS:4040-4048; Rust: `io::Error`; TS: an error with code `ENOENT`; Python: `FileNotFoundError` re-raised).
- **Derived state.** Mesh's `FileNotFoundException` is derived from the store reader's `null` (MESH:164, :195); Mesh no longer owns absence.
- **Forbidden writers.** `File.Exists` or any existence gate in MESH (negative grep `File\.(Exists|ReadAllBytes|Delete|Move|WriteAllBytes)|HasOlderCatalogLayout|ReadLegacySchemaCatalogEntry|WriteFileAtomically|ReadSingleFileSnapshot` over MESH: no match); a zero-length-means-absent shortcut; a writer's own read.
- **Shared paths.** Open, flush, commit, `ReadDurable`, `ReplaceDurable`. Rust `pull_all` RS:1243 also checks `is_absent` on the store and on the lock path before it takes the shared lock; that skips lock creation only, and the verdict stays with the reader.
- **Pinned by.** C#:`StoreReadabilityTests``ADanglingFileLinkIsAnIoErrorOnOpenNotAnEmptyStore` :208, `ADanglingDirectoryLinkIsAnIoErrorOnOpenNotAnEmptyStore` :185, and the commit variants :196, :219. Mesh: `ADanglingLinkIsAnIoErrorOnTypedWriteRawWriteAndReadNeverAMissingFile` :107 and `OnlyNothingAtThePathIsAMissingFile` :134 (`CultMeshSingleFileStoreTests.cs`). Rust: `a_dangling_symlink_at_the_store_path_is_an_error_and_nothing_is_written` RS:5832. TS: "readStoreBytes reports nothing at the path only when the lstat finds nothing too".
- **Known gaps.** The writer side still tolerates a dangling link: Rust `write_all_unlocked` RS:992-996 and TS `pushAll` TS:94-100 replace it on a whole-store write (`variants:follow_up:writer-dangling-link-tolerance`, owner `c2a-write-set`). What a replace does to a live link is `variants:follow_up:store-replace-cut` item 2. Windows junction behavior was proven at `42f05f66` and `b1ff2dcb`, not at the final head.
### C. Zero-byte file
- **Owner.** None separate: a zero-byte file is bytes the one reader cannot decode. C#:`ReadDisk` returns the bytes and `CSM.DeserializeSnapshot` refuses them (the `Length: > 0` shortcut is deleted). Rust: the `store_header` whole-array proof fails, `Undecodable`. TS: `readSingleFileStore` TS:158 wraps the decode error in `StoreUnreadableError`. Python: the `unpackb` failure is wrapped at PY:139-140.
- **Inputs/outputs.** Zero bytes in; a typed unreadable refusal out; every writer leaves the file as it was.
- **Derived state.** None. **Forbidden writers.** Any `length === 0`, `is_empty` or `Length > 0` branch before decode (negative greps in spec `cut-merge-r1.r3`: `Length: > 0 } bytes` in CS, `data.length === 0` in TS).
- **Shared paths.** Open, flush, commit, soft flush, Mesh typed and raw write, AUTH.
- **Pinned by.** Manifest row `zero-byte.bin` (refuses x4) in every walk above; Mesh `AReadOfAZeroByteFileIsRefusedAsUnreadableNotAsAMissingRecord` and `AZeroByteFileIsRefusedOnTypedAndRawWriteAndLeftAlone` (`CultMeshSingleFileStoreTests.cs:175`); the AUTH zero-byte case.
- **Known gaps.** None open.
### D. Directory at the path
- **Owner.** C#: the explicit clause in `ReadDisk` CS:4034-4035 (a directory that is not a link is an `IOException`; a directory link falls to the catch at CS:4045). Rust: `fs::read` fails with a non-`NotFound` error, kept as `io::Error` (RS:940-944). TS: `readFileSync` throws `EISDIR`. Python: `Path.read_bytes` raises an `OSError`.
- **Outputs.** An I/O error, never an empty store; the directory is untouched and the refusal text does not echo the path.
- **Forbidden writers.** Treating a directory as absent, or creating a store over it.
- **Shared paths.** Open, commit, Mesh read and write.
- **Cut line.** `mesh-single-file-reader` h1 (the C# clause). Rust, TS and Python were correct by construction of their reads.
- **Known gaps.** TS and Python have no test for a directory at the store path. No follow-up is registered; the natural home is `write-set-vectors` with the other readability pins.
### E. Lock
- **Owner.** C#:`AcquireLock` CS:4082 (`<file>.lock`, `FileShare.None`, 30 s wait), taken by `PushAll` CS:3766, `CommitBatchCore` CS:3795 and `ReplaceDurable` CS:4018, so Mesh writes wait for it. Rust: `with_exclusive_lock` RS:1038 and `with_shared_lock` RS:1031 (`fs2` flock on `<file>.lock`, `open_lock_file` RS:1162), taken by `pull_all` and `push_all`. TS: no cross-process lock; a per-store promise queue (`#enqueue`) only. Python: `threading.RLock` PY:119, in-process only.
- **Inputs/outputs.** A read-modify-write request; the lock is held across the read and the replace for every writer that merges.
- **Derived state.** A writer's header and merge base come from a read taken inside the lock (C# `PushAll`, `CommitBatchCore`, `ReplaceDurable`; Rust `write_all_unlocked`). TS and Python take them from a read outside any cross-process lock.
- **Forbidden writers.** Any Mesh write outside `AcquireLock` (the former `File.Delete` plus `File.Move` writer and its fixed `path + ".tmp"` are deleted). C# readers on open take no file lock (`ReadSnapshot`), and Rust's read-only snapshot takes none by design.
- **Shared paths.** The C# store and the Mesh single-file helpers; the Rust store.
- **Cut line.** `mesh-single-file-reader` h1.
- **Pinned by.** Mesh `AMeshWriteWaitsForTheStoreLock` (`CultMeshSingleFileStoreTests.cs:187`, deterministic) and `ConcurrentMeshWritersNeverErrorAndLeaveAReadableFile` (:261); mutant m3 (`ReplaceDurable` without `AcquireLock`) killed.
- **Known gaps.** TS and Python have no cross-process lock; the mesh spec cites ruling `variants:ruling:write-lock-python-now-ts-later`, and no follow-up in this record carries the remaining TS work. `variants:follow_up:store-replace-cut` item 3: no test that `ReplaceDurable` reads under the lock. Prior art: `docs/research/cross-process-file-locking-prior-art.md`.
### F. Replace (staging and atomic swap)
- **Owner.** C#:`WriteSnapshotAtomically` CS:4104 (unique temp `.{name}.{guid}.tmp`, `WriteThrough`, then `File.Replace`, or `File.Move` when nothing is there), called from `WriteSnapshot` CS:4062 and `ReplaceDurable` CS:4016. Rust: `replace_unlocked` RS:1010, `stage_and_replace` RS:3011, `replace_file_atomically` (unix `rename` RS:3095; Windows `MoveFileExW` with retry RS:3106), then `sync_parent_directory` RS:3198; outcomes `Rejected`, `NotReplaced`, `ReplacedNotDurable`. TS: `#writeAll` TS:124-141 and `renameWithRetry` TS:198 (unique temp, no fsync). Python: `_replace_all` PY:154-159 (fixed `<path>.tmp`, `Path.replace`, no fsync).
- **Inputs/outputs.** Encoded bytes in; the destination holds the old bytes or the new bytes, and a failed write removes its staging file (Rust pins the rename-failure path).
- **Derived state.** None.
- **Forbidden writers.** A second replace in a runtime. MESH's `WriteFileAtomically` is deleted; Mesh reaches the replace only through `ReplaceDurable`.
- **Cut line.** `mesh-single-file-reader` h1 (C#). Rust's replace predates the campaign; `merge-r1` h3 added its rename fault seam.
- **Pinned by.** Rust `a_write_whose_rename_fails_is_not_replaced_and_leaves_no_staging_file` (seam `WriteStep::Rename` RS:3212). Mesh `ACacheOpeningDuringMeshWritesNeverSeesAnEmptyStore` (:212, Linux only; it skips Windows).
- **Known gaps.** `variants:follow_up:store-replace-cut`: (1) the Windows primitive, decided by open question `variants:question:windows-store-replace-primitive` (probe: `File.Replace` gave 1,722 empty opens in 17,313 under Mesh writers; `MoveFileExW` and POSIX rename gave none; `docs/research/windows-store-replace-prior-art.md`); (2) what a replace does to a live symlink (Mesh pins parity with a cache commit, `AMeshWriteOverALiveLinkLeavesWhatACacheCommitLeaves` :287); (3) a read-under-lock test; (4) a live directory link misreported as unreachable. Python's fixed temp name lets two processes share one staging file (research doc above). Rust's Windows replace loop and its rename fault line are unmutated (h3, undone).
### G. Soft write decision (a soft flush writes only where nothing is)
- **Owner.** The one reader decides; the writer does not. TS: `pushAll` TS:79-104 (absent writes; a readable store is left byte for byte; an unreadable one is refused; a dangling link is refused when soft). Rust: the soft branch of `push_all` RS:1268-1280 (`!read_store_unlocked()?.absent` returns). C#: none; `CultMesh`'s `FlushAsync(bool soft)` accepts the flag and drops it (`src/GameCult.Mesh/CultMesh.cs:433-436`). Python: no soft flush. cultmesh-ts passes the flag (`packages/cultmesh-ts/src/index.ts:4073`).
- **Inputs/outputs.** A flush with `soft`; no write for a readable store, a write for an absent one, the reader's refusal otherwise.
- **Derived state.** None. **Forbidden writers.** A soft check that reads the file its own way (TS `readStore` once returned zero bytes as present; deleted in h4).
- **Shared paths.** TS `pushAll` and Rust `push_all` only.
- **Cut line.** `merge-r1` h4 (TS) and h6 (Rust).
- **Pinned by.** The manifest walks run a soft flush over every vector in TS (the `softPushAll` operation, TS test :2336) and Rust (`soft_push_all`, RS:5149), each with an absent and a dangling-link case; `a_soft_push_all_writes_where_nothing_is_and_refuses_a_dangling_link` RS:6019.
- **Known gaps.** C# and Python have no soft decision, and the C# Mesh flag is dead; no follow-up is registered (h6 `undone` names it). `writer-dangling-link-tolerance` applies to the non-soft branch.
### What no cut owns yet
`c2a-write-set` replaces `ReplaceDurable` with `ApplyWriteSet` (comment at CS:4012-4013) and decides `writer-dangling-link-tolerance`. `write-set-vectors` owns `c0-bad-canonical-in-manifest` and `legacy-vectors-all-runtimes`. `merge-main` owns `range-check-in-ci`. Imagination owns `store-replace-cut`.