diff --git a/docs/document-variants-cut.md b/docs/document-variants-cut.md new file mode 100644 index 00000000..987778ae --- /dev/null +++ b/docs/document-variants-cut.md @@ -0,0 +1,372 @@ +# CultCache document variants: cut map (draft) + +Date 2026-09-29. Imagination output for `docs/document-variants-target.md` (branch +`codex/document-variants`). Body read at `origin/main` `268e0ef`. This map owns means only; +the target owns ends and rulings 1-7. Nothing here is decided that the batch in section 4 +asks the operator to decide. + +## 1. Probe facts (run, not inferred) + +Probe: scratch clone `scratchpad/variant-probe` (commit `7b6de8e`, probe files only), run on +Yggdrasil via `ygg-verify.sh` with images `dotnet`, `rust`, `python:3.12-slim`, `node:22`. +Each runtime's current reader wrote a normal store, the bytes were altered four ways, and +the store was reopened. Output in `scratchpad/probe-out/*.txt`. + +| Altered store | C# | Python | TypeScript | Rust | +| --- | --- | --- | --- | --- | +| 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: + AetheriaEve `AetheriaRuntimeCatalogStore` (directory-store pages). +- **Kotlin reads no store files.** It sees CultNet raw snapshots only. + +## 2. Model page (step 0b) + +| Kind | Named by | Over time | Decided by | +| --- | --- | --- | --- | +| 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 unresolvable entries (Q2, Q4) | +| 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 an object-list element type; `""` means unset | minted by the cache on write when unset; copied unchanged into variants; a moved element is a new element | cache mints it; it is never retargeted | +| 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) | the writing store, from content | +| Variant slot on the wire (CultNet) | **empty cell**: no CultNet message carries a delta today | C5 | ruling 4 decides deltas; the message shape is C5's design | +| Dictionary entries, scalar lists, arrays | **empty cell**: no stable identity exists | whole-member Set only (Q7) | Q7 | +| Template (non-item) bases | **empty cell**: parked by ruling 6 | none | not in these cuts | + +## 3. Where the Body contradicts the target + +1. **"A member path in CultNet's grammar"** (ruling 1 and fork 1 text) has nothing to reuse. + CultNet selection deliberately has no path language. This map specifies a minimal slot + path (section 2) owned by `GameCult.Caching`. If CultNet ever needs paths, it can adopt + this one. Nothing is shared today, and nothing is invented twice. +2. **"Overrides are subject to the same soft drift rules as full records"** conflicts with + the ends list: "Loading refuses an override naming a member that does not exist on the + type". Under soft drift, a persisted slot missing locally is ignored with a warning, so + the two rules disagree for a dropped member. Below top level, the catalog has no shape to + drift against at all. See Q4. +3. **"Deletes first"** has little to delete: the feature is additive. The honest deletions + are the silent extra-slot skips and C#'s ignored header. C0 puts them first. + +## 4. Operator questions (one batch) + +**Q1. A plain `Upsert` of a whole document at a variant's key.** It happens when Studio's +current edit commits, when AetherDb migrations do `batch.Upsert(type, doc, key)`, and on a +CultNet remote write. +- (a) Refuse it loudly. A variant changes only through the variant API. Flattening is an + explicit, separate operation. +- (b) Diff the document against the base and store the difference. Ids make the diff + deterministic, but a value set equal to the base stops being an override without anyone + saying so. +- (c) Flatten silently (today's behaviour by default). +- **Recommend (a).** Affects C1's write path, C3's edit commit, and every AetherDb migration + (each skips variants or uses the variant API). + +**Q2. A base edit that removes an element some variant overrides.** +- (a) Refuse the commit unless the same batch clears or retargets those overrides. Studio + offers keep, retarget or clear at edit time, and "keep" means the base edit is not + committed. The store never holds a conflict, and loading refuses one. +- (b) Persist the conflict. The override goes inert, readers get the resolved document + without it, and Studio lists it. +- **Recommend (a).** Option (b) makes a reader's resolved document silently differ from what + the author wrote. Affects C2's validation, C3's rebase UI, and migrations that remove + behaviours. + +**Q3. Scope of "ids everywhere".** +- (a) Every object-list element in every registered document type, in all 28 consumers. The + registry refuses a type whose element types lack an id. +- (b) Only document types that opt in with `[CultVariants]`. For those, the registry requires + `[CultElementId]` on every object element type reachable through their lists, including + union bases. +- **Recommend (b).** Option (a) breaks 28 registries for a feature that one consumer uses. + Affects C2 and Aetheria's adoption cut. A `BehaviorData` id needs a base slot above the + subclasses' highest key (32), which adds about 30 nil bytes per behaviour unless Aetheria + renumbers. + +**Q4. Schema drift under overrides** (contradiction 2). +- (a) Refuse loading when an override names a member missing at any depth, or holds a value + that will not decode into the member's current type. Record this as a stated exception to + soft drift in `cultcache-schema-compatibility.md`. +- (b) Top-level overrides follow catalog soft drift (ignore with a warning); nested ones are + refused. +- **Recommend (a).** It follows the target's ends and is one rule. The cost: after a type + change drops an overridden member, the catalog must be rewritten before the new build opens + it. + +**Q5. Format version.** +- (a) Write `cultcache.store.v2` only when the store holds a variant. Stores without variants + stay byte-identical `v1` for every consumer. +- (b) Always write v2 after the upgrade. +- **Recommend (a).** The empty payload remains the tripwire for pinned old C#, which ignores + the header. Affects C0 and C1. + +**Q6. Slice scope.** Should the directory store and CultNet refuse variants loudly until +their own cuts? The directory store's only consumer is AetheriaEve's raw reader. CultNet +must carry deltas (ruling 4), and no message can yet. +- **Recommend yes.** It is two refusals. Otherwise C1 must design CultNet messages first. + Affects C1's size and the order of C5. + +**Q7. What is addressable below a member.** The proposal: object-list elements by id only. +Dictionary entries, scalar lists and arrays are replaced whole. +- **Recommend yes.** Aetheria's cases (`Hardpoints` whole; `Behaviors[id]...Max`) fit. Keyed + dictionary addressing can be added later without a format change. Affects C2. + +## 5. Cuts, in order + +All cuts are in `F:\Projects\CultLib`. Rebase `codex/document-variants` (docs only) onto +`origin/main`. Each cut is one Hands pass on that branch and merges to main after its Soul +pass. Probes show that a C1 writer can merge safely before other runtimes change, because +every reader refuses it. Verification uses `ygg-verify.sh /f/Projects/CultLib ''`. +The command strings below are the ``. Keep the job's exit status last. + +Overlap: BP-1 (`cultmesh/rust-content-plane`) edits `docs/runtime-parity-scope.md`, which +C0 also edits. Rebase after BP-1 merges. `codex/fix-node24-ajv-esm` has no diff against main. + +### C0. Tripwires: every reader refuses what C1 will write, and names the record + +Deletes: +- The silent extra-slot skip in the C# record decoder + (`CultDocumentMessagePackSerialization.ReadPersistedRecord`'s trailing `Skip` loop) and + its copy in `CultMeshSingleFileDocuments.ReadLegacyPersistedRecord`. +- The `?? "cultcache.store.v1"` defaulting of the header. +- Python `raw_record[:4]` slicing. +- The TypeScript and Python fallthrough to the legacy envelope decoder when the header is a + `cultcache.store.*` string they do not know. + +Adds, one rule in each runtime: a snapshot whose header is not `v1`, or whose record has more +than 4 slots, is refused with a message that names the version or the record key, and says +the store needs a runtime that resolves variants. C# accepts `v1` only, until C1 adds `v2`. + +Keeps: every v1 byte and every v1 test. + +Verification (rule: "unknown header or extra record slot is refused by name"). Each runtime +gets one test, and each test's negative is fixture B from section 1 (full payload plus slot +4) loading: +- `dotnet`: `dotnet test tests/GameCult.Caching.Tests --filter FullyQualifiedName~PreCut2StoreFormat` +- `node:22`: `npm install --no-audit --no-fund && cd packages/cultcache-ts && npm test` +- `python:3.12-slim`: `pip install -q msgpack -e packages/cultcache-py && python -m unittest discover packages/cultcache-py/tests` +- `rust`: `cd packages/cultcache-rs && cargo test` (message only; Rust already refuses) + +Subtraction: about −20 lines of skip and fallthrough; +4 small refusals and 4 tests. Build: +the 4 cache packages only. + +### C1. C# core: variant records, top-level Set overrides, resolution in the admitting hold + +Adds: +- **`CultPersistedRecord.Variant`**, record slot 4: `[baseKey, overrides[]]`. Payload slot 3 + is empty for a variant. An override is `[op, path[], id, value]`. + - Owner: `GameCult.Caching`. + - Consumer: both file stores and Studio. + - Invariant: an old reader refuses rather than misreads (section 1). + - Why no existing owner serves: the payload cannot hold the delta (probe D). +- **`CultStoredDocument.Variant`** (base key and overrides; null for plain records). + `Document` stays the resolved view. +- **A codec port on `CultCache`**: the `(serialize, deserialize)` pair `CultInspectorModel` + already takes, lifted. + - Owner: `GameCult.Caching.MessagePack`, supplied by `CultCacheMessagePack.Create`. + - Consumers: resolution and the inspector model. + - Invariant: one codec per cache; clones and override values use the store's encoding. + - `CultInspectorModel`'s constructor takes the port instead of its own pair, so there is one + pair, not two. + - A cache without a codec refuses variant writes. +- **Write API** on `CultCacheBatch`, with single-call equivalents: `UpsertVariant(key, baseKey, + overrides)`, `Flatten(key)` (Q1a), `Remove` unchanged. Rebase is `UpsertVariant` with a new + base. +- **`Validate` resolves** every admitted variant and every dependent of an admitted or + evicted base, in base-first order, against the post-batch set. It refuses, naming keys: + a cycle; a missing base; a different concrete type; a global type; an evicted base with + surviving variants; an override slot missing on the type (Q4); a value that will not + decode; a variant whose resolved `[CultName]` equals its base's (R6); a plain `Upsert` at a + variant key (Q1a). +- **`Apply` re-resolves dependents** in the same hold. Each emits `Updated` and is re-indexed. + A base change stamps no dependent `storedAt`. +- **Header**: the store writes `v2` iff it holds a variant (Q5a) and reads `v1` and `v2`. +- **Refusals** (Q6): `DirectoryMessagePackBackingStore` push and commit of a variant; + `CultNetDocumentRegistry` row building for a variant; `CultMesh.ReadSingleFileDocument` of + a variant key. +- **Contracts**: the variant record section in `cultcache-persistence-format.md`; the Q4 + exception in `cultcache-schema-compatibility.md`; a "variants: C# resolves; others refuse" + row in `runtime-parity-scope.md`. + +Authority map: +- Owner: `CultCache.Validate`/`Apply` owns the resolved view. +- Inputs: stored deltas, base records, the codec. +- Outputs: the resolved `Document`, index entries, changes. +- Demoted: the resolved `Document` is no longer a writable owner for a variant key (Q1a). + `storedAt` is the delta's identity only. +- Forbidden writers: store `Loaded` callbacks never resolve; Studio never writes a resolved + document to a variant key; CultNet never emits a variant row. +- Shared paths: a load (`Admit` with a source), a single write, a batch and a remove all pass + the same `Validate` and `Apply`. There is no second resolution path. + +Verification. New `VariantTests.cs`; each rule gets a positive and a named negative: +- a load with a variant resolves; +- a base edit updates the variant's `Get`, `GetByName` and `GetByIndex`, and `Watch` sees both + changes inside one publication, with no gap in `Sequence` between them. Negative: a + subscriber observing the base change must already read the new variant. +- `Expect` on the variant survives a base change; +- each refusal case, and the refusal message names both keys; +- the header is `v1` without variants and `v2` with them; +- a read-only store resolves; +- a variant round-trips byte-identically through flush. + +Cross-runtime vectors: the Caching interop peer gains `write-variant --file`. A test in +`packages/cultcache-ts/test/cult-cache.test.ts` asserts that TypeScript refuses that file by +name; Python and Rust tests read the same fixture from `contracts/cultcache/variant-store.cc`. + +Command, `dotnet`: +`dotnet test tests/GameCult.Caching.Tests && dotnet test tests/GameCult.Networking.Tests --filter FullyQualifiedName~Registry`, +then the C0 commands for `node:22`, `python` and `rust` against the new fixture. + +Subtraction: the inspector's own codec pair collapses into the port; otherwise net additive, +estimated +350 to 450 lines of core and +400 of tests. Build: the Caching and MessagePack +projects, Caching.Tests, the Networking test filter, the interop peer. + +### C2. C# element identity and nested paths (Set, Insert, Remove) + +Adds: +- **`[CultElementId]`** (a string member) and **`[CultVariants]`** (a document-type opt-in, + Q3b). The registry refuses an opted-in type with an id-less object element type reachable + through a list, union bases included. +- **Minting**: on write, the cache mints ids for unset ids in opted-in types, and refuses a + duplicate id within one list. +- **Path steps below top level.** +- **Insert** anchored after an id, where `""` means the head. An inserted run sits + immediately after its anchor and before base elements added there later. +- **Remove** by id. +- **Canonical order**: removes, then inserts in list order, then sets by path. +- **Q2a refusal**: a base commit that orphans an override is refused. +- **Q7**: whole-member Set for dictionaries, scalar lists and arrays. +- **`CultCache.MintElementIds()`**: the one-shot rewrite for existing stores (ruling 1). It + upserts every record of opted-in types through the normal path. + +Keeps: the C1 format unchanged. C2 fills path shapes that C1 refused. + +Verification: +- Two look-alike elements (two of one union subtype) get different overrides, and a base + insert before them retargets nothing. +- Rebase re-applies overrides by id. A missing id is refused, and the message names the + variant, path and id. +- A Set on a removed element is refused. +- Minting is idempotent. +- An opted-in type without ids is refused; a type that has not opted in is untouched. This is + the negative for Q3b's blast radius. +- A C#-written nested-override fixture joins `contracts/cultcache/`. + +Command: `dotnet test tests/GameCult.Caching.Tests`. + +Estimate +300 lines and +350 of tests. Build: as C1, without Networking. + +### C3. Studio: edit the delta + +Adds, in `CultInspectorModel` (engine-free; the lowering stays a renderer): +- For a variant edit, each member path is marked inherited or overridden. +- `Commit` writes `UpsertVariant` with overrides equal to the prior overrides plus a + structural diff by id. It is deterministic because ids exist. +- An explicit "override" pins a value equal to its base's. +- `ClearOverride(path)`. +- `CreateVariant(record)` next to `Duplicate`; it prompts for a name (R6). +- `Rebase(record, base)` returns a conflict list for Q2a before commit. + +Lowering, in `CultCacheStudioWindow`/`Drawers`: an overridden marker and a revert control per +member, a base link, and variant creation and rebase controls. + +Verification: +- `CultInspectorModelTests`: the diff produces exactly the touched paths; clear removes one + entry; the pin survives an equal value; rebase conflicts are listed. +- Negative: a Studio commit never calls a plain `Upsert` at a variant key. + +Only the operator can check these in Unity against a copy of `Aetheria.cc`: +- the markers read correctly; +- clearing reverts the value live; +- a hull variant replaces `Hardpoints`; +- a laser variant changes one behaviour's `PerformanceStat.Max`; +- a base edit shows in the open variant. + +Then tag `caching-unity-v1.5.0`. **This is the point where Aetheria is unblocked.** + +Estimate +250 model, +150 lowering, +250 tests. Build: Caching.Tests. The Unity package is +checked by the operator in the editor. + +### C4. Aetheria adoption (Aetheria repo, its own cut, after the release) + +- Bump the package to `v1.5.0`. +- Add `[CultVariants]` to `HullData`, `EquippableItemData`'s concrete types and `ConsumableItemData`. +- 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 | +| weaponitemdata | 18 | ChargeBlast SG / ChargeBlast+- (14 of 253 leaves differ); pswarm / scorched void policy; Earp / 6k Shooter; Spectra / ColdFire | 4 | +| hulldata | 5 | Longinus / LonginusX (only `Behaviors`, `Hardpoints` and two cosmetic members differ) | 1 | + +That is 15 of 55 equipment records. In most of these families the difference lies inside +`Behaviors`, so element-level overrides (C2) are what pay. Top-level overrides alone would +still copy each behaviour list. + +## 7. Consumers + +There are 28 projects: C# 13, Rust 9, TypeScript 10, Python 1, Kotlin 0 (Explore survey; +dependency-level for most, read in detail for Aetheria and AetheriaEve). +- **See variants:** Aetheria through C# `Get`, `GetByName` and `CultRecordRef` (resolved + documents only), CultCache Studio (deltas), and AetherDb tools (must honour Q1). +- **Must stay oblivious or refuse:** every other consumer. No other project reads + `Aetheria.cc`. Raw readers refuse through the empty payload plus C0/C1: `CultMesh` + single-file reads, AetheriaEve's directory-page reader (the directory store refuses + variants, Q6), and Mimir (writer only). diff --git a/docs/document-variants-target.md b/docs/document-variants-target.md new file mode 100644 index 00000000..1adbbe50 --- /dev/null +++ b/docs/document-variants-target.md @@ -0,0 +1,146 @@ +# CultCache Document Variants: Target + +Date: 2026-09-17 + +Status: target, before any substrate map or cut map. This document owns the ends. +A cut map, written after an Imagination pass over the Body, will own the means. +Branch: `codex/document-variants`. + +## Why + +A CultCache record today is always complete. To make a bigger laser from a +laser, an author copies the record and re-specifies every stat. For a ship hull +with several hardpoint layouts, every layout is a full copy of the hull. When +the base changes, each copy has to be found and edited by hand, and copies drift +apart silently. + +The first consumer is Aetheria's catalog (`F:\Projects\Aetheria\GameData\Aetheria.cc`), +edited in CultCache Studio: + +- **Hull variants:** one hull with several hardpoint layout options. A variant + replaces the whole `HullData.Hardpoints` member. +- **Item variants:** a bigger laser. A variant changes stats nested inside the + design's behavior list, for example one behavior's `PerformanceStat.Max`. + +## Rulings (operator, 2026-09-17) + +- **This is a CultCache feature, not an Aetheria mechanism.** Operator: "Yes, this + is absolutely a CultCache feature, write it up in CultLib". +- **A variant is stored as a delta from its base and resolved by the cache.** A + record stores a reference to its base plus a sparse set of overrides. The + cache resolves it; readers see complete documents. The operator chose this + over two alternatives. Authoring-only variants in Studio over flattened stored + copies leave a derived cache pretending to be data. Per-type variant records + in each consumer rebuild the feature bespoke for every type. + +## Ends + +- **Readers see complete documents.** `Get`, `GetAll`, lookups by name and by + index, watches and snapshots return resolved documents. No consumer outside + the cache and its authoring tools handles a delta. +- **A base change reaches its variants.** Editing a base changes every variant + that does not override the edited value. Watches, indexes and name lookups + observe that change on the variants too, in the same commit that changed the + base. +- **A variant is a record.** It has its own key and name, and references to it + behave like references to any record. In Aetheria a lot's design and a + product's design can name a variant directly. +- **Studio edits the delta.** It shows which values a variant inherits and which + it overrides, and it can clear an override so the base value applies again. +- **Loading refuses a broken variant loudly and names the records.** Cases: + - a cycle of bases; + - a base that does not exist; + - a base whose type the variant's type cannot inherit from; + - an override naming a member that does not exist on the type. + + Deleting a base that still has variants is refused. + +## Invariants that must survive + +- **Wire parity** for the persisted format (`src/GameCult.Caching/Contracts/cultcache-persistence-format.md`, + `docs/persisted-record-encoding.md`). Every runtime that reads a store holding + variants resolves them identically, or refuses the store loudly. Which + runtimes resolve is decided in the map, against + `docs/runtime-parity-scope.md`; none may misread silently. +- **One home store per type**, and atomic single-store commit + (`cultcache-store-composition.md`). A base change and the resulting variant + changes are one commit, not a repair that runs afterwards. +- **Schema compatibility** (`cultcache-schema-compatibility.md`): overrides are + subject to the same semantic identity, soft drift and hard rejection rules as + full records. +- **Compare-exchange** (`Expect`, `ExpectUnchanged`) keeps a single meaning for + variants. The map settles whether it tests the stored delta or the resolved + document; it must not mean both. + +## Fork rulings (operator, 2026-09-17) + +- **1, granularity: stable element identity, ids everywhere** (operator, 2026-09-17: "ids + everywhere"). + - Every object element in a list carries a stable id, minted when the element is created. + This is the approach of sequence CRDTs (RGA, YATA/Yjs, Automerge, Fugue). + - Matching algorithms (Myers, diff3, GumTree) are heuristics. They fail on look-alike + elements such as two `EnergyDraw` behaviors, which is why identity is minted, not + inferred. + - An override addresses an element id plus a member path in CultNet's grammar. An + insertion is a new element anchored after an existing id; a removal names the + removed id. Index addressing is out. + - Rebasing is deterministic. An override whose element id exists in the new base + re-applies. One whose element is missing is an explicit conflict that Studio shows, + to keep, retarget or clear; it is never silently retargeted. + - Existing stores get ids minted once, by a rewrite. +- **2, where resolution lives: at load, kept in memory** (operator: "agreed"). Resolution + computes a variant's complete document from its base chain and its overrides. + - The cache holds both the stored delta and the resolved document. + - A base edit re-resolves its variants inside the same commit, so watches and indexes + observe the change atomically. + - Compare-exchange (`Expect`) tests the stored delta. The resolved document is derived. +- **3, cross-store bases: no.** A variant and its base live in the same store. +- **4, CultNet: deltas.** +- **5, runtimes: C# first.** TypeScript, Python, Rust and Kotlin refuse a store + or snapshot containing variants, loudly, until each implements resolution. +- **6, type change: the same concrete type only** (operator: "agreed"). + - An override can only name members the base type has, and rebasing requires the same + type. + - A variant whose type differs from its base's is refused loudly. + - Subtype variants are parked until real content needs a template base. A record + flagged as a template, not a real item, may be the smaller feature. +- **7, clearing and rebasing.** + - Clearing an override removes that member from the variant's stored delta. A member + that is not overridden is not stored. + - Rebasing (changing a variant's base) is supported. Operator: "rebasing is a nice + tool to have, things can get annoying otherwise". + +## Open forks, for the Imagination pass to map and the operator to rule + +1. **Override granularity.** + - Top-level members are enough for hardpoint layouts. + - A bigger laser needs overrides inside list elements. Addressing list + elements by index is fragile: an insertion into the base silently retargets + every variant's overrides. + - Candidates: stable element identity (for example a behavior's type within its + design); replacing whole list elements only; or addressing paths. + - CultNet's selection work already resolves member paths per leaf type, + inherited members included (`docs/cultnet-selection-cut.md`). A variant's + delta addressing should reuse that grammar, not invent a second one. +2. **Where resolution lives:** at load in the cache core, lazily on read, or as a + materialized view the cache maintains. This affects memory, watch cost, and + the compare-exchange meaning. +3. **Base and variant across stores.** Can a variant in one store name a base in + another (a run-store variant of a catalog record)? This interacts with + one-home-store routing and read-only stores. +4. **CultNet.** Do variants travel as deltas, or resolved? A subscriber that + receives only resolved documents needs no resolver; one that edits needs + deltas. +5. **Runtime scope.** Which of C#, TypeScript, Python, Rust and Kotlin resolve, + and which refuse a store containing variants. +6. **Type change.** May a variant's type be a subtype of its base's type, + adding members, or must the two types match? +7. **Clearing and reparenting.** What clearing an override writes, and whether + a variant can change its base. + +## Not in scope + +- Scaling or formula variants ("every stat times 1.5"). An override states a + value. Generated authoring can compute values. +- Any Aetheria catalog content. Aetheria adopts the feature after a release, as + its own cut.