Imagination pass imagination-cl-union-parity: MessagePack-CSharp's union miss branch read and run, a CultCache-layer arm guard proved without a fork, Aetheria's and StreamPixels' stores walked for arms and slots, and the Rust, TS and Python slot and union behaviour measured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
19 KiB
CultCache Union Parity: Map
Campaign cultlib-gaps, follow-up cultlib-gaps:follow_up:union-parity-and-unknown-arms (from
thing2:follow_up:cultcache-union-parity), under operator ruling thing2:ruling:contract-decode-tolerance
("CultCache refuses unknown union arms in every runtime, the C# reference included"). This file holds body facts,
the model page and rationale; the cut specs and the question are documents in the mind and cite this file by
section and fact number.
Pinned base: origin/main at aaf0d7f6 (2026-10-10). Imagination pass imagination-cl-union-parity, session
self-2026-10-10c-eureka. Probes live under the session scratchpad, imagination-cl-union-parity/ (cs payload
probe, cs2 store probe, rs payload probe, py store walkers, il decompiled MessagePack-CSharp). None of it is
committed. Earlier evidence is not repeated here: Eve docs/thing2-map.md P12-P13 (the cross-runtime probe) and
docs/research/cultcache-union-decode-tolerance-prior-art.md (prior art).
Cut order
union-refusal-cs: the C# reference refuses an unknown union arm on read and a subtype the union does not name on write. Ruled; nothing blocks it.union-golden: the recursive-union golden and the arm refusal goldens intests/vectors/union-parity/, written by the C# reference and held by its tests.union-derive-rsandunion-codec-ts, in either order or together: cultcache-rs and cultcache-ts gain a union (and, in TS, a slot record) codec that writes the reference shape and decodes the golden byte-identically.union-codec-pyfollows the same golden for cultcache-py.slot-tolerance: extra trailing slots, percultlib-gaps:question:extra-trailing-slots. Blocked on the operator; it changes every runtime's slot rule at once, so it waits for 1-3 rather than reopening them.union-parity-release: changelogs and tags, Breaking wheredocs/semver-policy.mdsays so.
Thing2's cut-contract-rs waits on step 3 (Rust) only: its first step pins cultcache-rs at the commit that lands
union-derive-rs and re-runs P12 or the union-golden vector both ways.
Why this order: the C# reference is the wire authority, so its refusal and its golden come first and the other
runtimes are held to bytes C# wrote, as document-variants-c1 was (docs/runtime-parity-scope.md). Arms are
ruled and slots are not, so the slot rule is its own cut rather than a blocked step inside every runtime's cut.
Model page (step 0b)
| Kind | What names it | Over time | Who decides |
|---|---|---|---|
| Union arm | An integer key, [Union(k, typeof(Arm))] on the abstract base (C#), assigned by the union's author. The wire carries [k, armSlots]; the arm's type name never travels. nil is a null reference, not an arm. |
Added: a key never used before. Retired: the attribute is removed or commented out and the key is never reused (Aetheria Behaviors.cs:160,173; Aetheria-legacy Behaviors.cs:37-38). Renamed type: free, the key is the identity. Unknown to a reader (a newer writer's arm, or a retired arm in an old store): refused, naming the union and the key (ruling). |
The union's author assigns keys. The C# reference defines the shape; every runtime keeps parity with it (doctrine, runtime-parity-scope.md). |
| Arm subtype on write | The value's runtime type must be one the union names. | A subtype the union does not name is today written as nil (U4). After cut 1 it is refused. |
The union's author, by naming the arm. |
| Arm slots, record slots | [Key(n)] integers, base-class keys included (an arm is a flat array). Gaps are written nil. |
Added: appended at the next free key. Retired: kept as a gap, never reused (Aetheria ItemInstance.cs:35, EntitySerializer.cs key 5). Missing in an older store: the reader's default (C# field initializer; Rust and Python need a declared default). Extra past the reader's last key: skipped silently today in every runtime (U3, U6); the question decides. |
The type's author. The slot rule is CultCache's (src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md). |
| Serde-enum member (Rust) | serde's external tag: the variant name ({"Name": fields}, a unit variant the bare string). |
Persisted already by Rust consumers (section C). They keep their bytes: the union derive is opt-in and serde enums are untouched. | The Rust consumer. Not a cross-runtime shape. |
U. Mechanism facts (C# reference, Rust, TS)
- U1. The miss branch is
Skipthennull, in both union formatters MessagePack-CSharp 3.1.7 can hand out.DynamicUnionResolver.BuildDeserialize(decompiled fromMessagePack.dll3.1.7 net9.0 with ilspycmd): afterReadArrayHeader() == 2(elseInvalidOperationException "Invalid Union data was detected") andReadInt32, a non-zero-start key set is mapped throughkeyToJumpMap.TryGetValue, a miss sets the index to-1, theswitchdefault callsreader.Skip(), and the result local staysnull. The source generator'sUnionTemplate(MessagePackAnalyzer3.1.7,MessagePack.SourceGenerator.dll) emits the same:default: reader.Skip(); break;withresult = null. Its serializer writesnilwhentypeToKeyAndJumpMaphas no entry forvalue.GetType(). - U2. The C# reference decodes an unknown arm to null and does not re-encode to its bytes. Probe
cs(payload built by hand withMessagePackWriter, decoded withCultDocumentMessagePackSerialization.OptionsFor): unknown key 9 in a union field givesRoot=NULL, in a listItems=[TextNode,NULL]; re-encode equal: False in both. The formatter the reference composite hands out for the union isMessagePack.Formatters.NodeFormatter1(DynamicUnionResolver's emitted type; the probe assembly carries no generated resolver). - U3. Missing and extra trailing slots, C# payload level. Same probe: a payload one slot short decodes with
the field initializer (
Added=42); a payload one slot long decodes with the extra slot skipped. Neither re-encodes to its bytes. - U4. The reference writes an unnamed subtype as nil. Same probe:
Root = new RogueNode()(a subclass the union does not list) serializes to94 a2 64 32 c0 90 2a: the root slot isnil, and it reads backRoot=NULL. Silent loss on the write side, the mirror of U2. - U5. Refusal can live at the CultCache layer; no MessagePack-CSharp fork. Same probe,
strictoptions: a resolver placed ahead ofStandardResolverreturns, for any abstract or interface type with[Union]s, a formatter that peeks (CreatePeekReader) the[key, ...]header, throwsMessagePackSerializationException("union Node has no arm 9")for a key the union does not list, refuses to serialize an unnamed subtype, and otherwise delegates to the formatterStandardResolverwould have handed out. Baseline round-trips byte-identical; both unknown-arm cases and U4 are refused; slot behaviour is unchanged. Every C# decode path reaches the composite throughCultDocumentMessagePackSerialization.OptionsFor(CultCacheMessagePack.CreateCodec,DirectoryMessagePackBackingStore.cs:345,CultMesh.cs:2779,3493,3499,CultMeshPrimitives.cs:1158,1230,2357), so one resolver covers the cache, the directory store and CultMesh. Consumer resolvers named by[CultCacheFormatterResolver]sit ahead ofCultDocumentResolvertoday (CultDocumentMessagePackSerialization.cs:58-62); the guard goes first and delegates to the rest of the composite, so a consumer formatter still encodes the arm while the key check still runs. - U6. A top-level extra slot is dropped on write-back. Probe
cs2: a C#-written store, mutated as a newer writer would (catalog member slot 2 added, payload slot 2"newer writer data"), is pulled, the record edited and flushed. The read succeeds, and the written payload is['n1', 'edited by the older build']: the newer writer's slot is gone. (The schema id was left unchanged, so the migration report saidKind=Exact; with a changed id the documented drift path reportsIgnoredExtraSlots,CultCache.cs:840-924, but writes back the same way.) - U7. Rust today. Probe
rs(cultcache-rsataaf0d7f6, payloads built withrmpv, decoded withrmp_serde::from_slice): a serde enum's unknown variant is refused (unknown variant 'Exploded'); the C# arm shape[0, [...]]is refused (expected variant identifier); a non-nil value in a gap slot is skipped; a missing trailing slot is refused (missing field 'added') unless the field is#[cultcache(default)]; an extra trailing slot is skipped (cultcache-rs-derive/src/lib.rs, theIgnoredAnyloop after the slot loop). The derive accepts named structs only (lib.rs:51-67) and writes an empty member list into the catalog (src/lib.rs:2706). - U8. TypeScript today. No typed codec: with no
formattera document encodes as a MessagePack map of the zod-parsed object and decodes raw (cult-cache.ts:679-692;@msgpack/msgpackencode({id:'d',version:7})is82 a2 69 64 ..., a fixmap, where the reference writes an array); positional slots exist only in hand-written formatters (test/cult-cache.test.ts:1263-1310). P13 measured that a hand codec matches whichever encoding its author chose. - U9. Python today.
define_database_entry_type(packages/cultcache-py/src/cultcache_py/documents.py:185-276) is a slot codec: gaps writtennil, extra slots ignored, a missing slot defaulted or refused. No union codec. - U10. The written contracts.
cultcache-schema-compatibility.md:75-91classifies "persisted slot is missing locally and can be ignored" as soft-migratable drift, reported asIgnoredExtraSlots;cultcache-persistence-format.md:215-218says a store reader "never skips a slot it does not understand" for record envelope slots, and every runtime refusesextra-slot-full-payload.msgpack(tests/vectors/document-variants-c0).docs/persisted-record-encoding.mdsays consumers verify canonicality by re-encoding and comparing bytes. The two contracts disagree about payload slots; the question below is about that disagreement. - U11. Fixture home.
tests/vectors/<name>/: committed bytes every runtime's tests read (document-variants-c0by Pythongenerate.py,document-variants-c1written by C#), consumed bytests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs,packages/cultcache-rs/src/lib.rs,packages/cultcache-ts/test/cult-cache.test.tsandpackages/cultcache-py/tests/test_cultcache.py. The CultNet precedent for a reference-written vector isCULTNET_WRITE_VECTORS=1(docs/cultnet-error-contract-cut.md:170).
A. Aetheria (C#, the only consumer persisting [Union]s)
Aetheria consumes the reference through Unity packages pinned by tag (Packages/manifest.json:55-57:
caching-unity-v1.4.0, cultlib-unity-v1.0.60), so a CultLib change reaches it only when it bumps the pin.
Its only consumer resolver is MathResolver (AssemblyInfo.cs:4), which supplies no union formatter.
- A1. Unions inside persisted documents (Aetheria
aac2cf91,Assets/Scripts/ServerShared):BehaviorData(Behaviors/Behaviors.cs:147-191, keys 0-11, 15, 16, 18, 20-24, 28, 31-40; 12 and 26 retired, 14, 17, 19, 25, 27, 29, 30 commented out) inList<BehaviorData> Behaviorsof the item data documents (ItemData.cs:346,368);WeaponData(Behaviors/Weapon.cs:15-18);EntityPack(EntitySerializer.cs:172-173) inaetheria.savedzonecontents and its ownChildren;ItemInstanceandCraftedItemInstance(ItemInstance.cs:18-31, a union whose arms are also a union) in entity cargo and docking bays (EntitySerializer.cs:186-187);Provenance(Provenance.cs:96-98) inaetheria.provenanceledger(Provenance.cs:11-15,Lot.Origin);SavedActionBarBinding(SavedGame.cs:221-223) inaetheria.savedgame; the input layout rows and keys (InputLayout.cs:29-50) inaetheria.inputlayout.Brush(Environment.cs:73) was not traced to a persisted document. - A2.
GameData/Aetheria.cccarries no unknown arm. Walkerpy/store_unions.pyover the git-tracked game database (12.7 MB,cultcache.store.v1, 238 records, 13 catalog entries):Behaviorsarm keys {0:3, 1:2, 2:10, 3:5, 4:2, 5:2, 6:3, 8:7, 9:23, 11:2, 16:4, 23:7, 24:5, 31:1, 32:1, 34:1, 35:2, 37:4, 38:10, 39:2, 40:1}; none retired or commented out; nonilelement. - A3. The run save carries no unknown arm, and does carry missing trailing slots. Walker
py/run_unions.pyoverGameData/run.cc(2026-09-30):EntityPackarms 0 and 1 (3 each),ItemInstancearm 2 (12),Provenancearm 0 (64),SavedActionBarBinding9nil(empty bar slots). TheEntityPackarms carry 21 slots (orbital) and 20 (ship) against today's 22: theBossslot (key 21, added 2026-10-07 indb4617d8) is missing, so today's Aetheria reads this save only because a missing trailing slot defaults.py/gaps.py: no non-nil retired slot (ItemInstance 2, 9, 10; EntityPack 5).GameData/stale-2026-09-22/run.ccis the same shape;player.ccholds onlyaetheria.playersettings.py/slots.py: every top-level record matches its own store's catalog exactly. - A4. What changes for Aetheria. After it adopts a CultLib with cut 1, its current stores load unchanged
(A2, A3). A store written later by a build that has added an arm is refused by a build that lacks it, naming the
union and key, where today the arm silently becomes
null(a gear item quietly loses a behaviour, a lot loses its provenance) and the next save writes the loss back. Builds already pinned to 1.0.60 keep the null path; a shipped binary cannot change. Aetheria-legacy does not use CultCache and has no store. - A5. Other C# stores.
%LOCALAPPDATA%Low/GameCult/Aetheria/aetheria-unity.cc(2026-08-08, 1.2 KB) is acultcache.store.v2.directory-indexedheader from an older layout, not a union carrier.
C. Rust, TypeScript and other consumers
Eyes audit for this pass (session self-2026-10-10c-eureka), git grep at each repo's origin/HEAD, Rust reach
by a lexical script (it can over-count; it does not under-count named fields). Aetheria and CultLib's tests excluded.
- C1. Rust: no
DatabaseEntryrecord crosses runtimes as a union, and none uses the reference arm shape. Records that reach serde enums, each keeping its bytes because the derive is opt-in: Huginnepiphany.pipeline.resolution.v2(ResolutionOutcome, external tag,eureka-pipeline/src/lib.rs:746, live on Yggdrasil); Idunnidunn.deployment_transactionandidunn.admitted_generation(v4 and legacy forms; internally tagged#[serde(tag = ..., deny_unknown_fields)]enums incontrol_plane.rs,deployment_plan.rs, and two#[serde(untagged)]enums kept so older transactions decode,drivers.rs:259,387; live at/var/lib/gamecult/idunn/control.cc); CodexConnectorgamecult.codex.replay_record.v1(CodexReplayState,lib.rs:1017); Epiphany's Atlas, model-request, persona and receipt records (internal, adjacent, external and untagged representations;.ccfiles underF:\Projects\.epiphany-runtime\and on Yggdrasil). Unit-only serde enums (a bare string) are widespread in Idunn, Huginn (Faculty, the pipeline's unit enums), CodexConnector and Epiphany. No enum reaches any record in Odin, Muninn, Ghostlight, Huginnmind-body, or cultnet-rs and cultmesh-rs production records. None of these is read by another runtime as a union today, so none is affected by the arm refusal; the derive changes nothing for them. - C2. TypeScript: no consumer encodes a tagged union. The only hand formatters are StreamPixels
apps/service/src/realtime/overlay-state-documents.ts:190,232,277(positional arrays, no union). Bifrost, Heimdall, Hermodr, Odin scripts, Stonks, Vili, VoidBot (vendoredcultcache-ts) and weksa have none. - C3. C# outside Aetheria: StreamPixels' overlay declares unions; its tracked store holds none of them.
apps/overlay-unity/Assets/Scripts/Assembly-CSharp/(8d40541):ActivationCondition(10 arms),BattleEffect(2),StatusEffect(4),ItemInstance(3),EquippableItemInstance(2), reachable fromstreampixels.status,streampixels.action_item,streampixels.skilland gear documents. Walkerpy/sp_catalog.pyoverapps/overlay-unity/Data/catalog.cc(cultcache.store.v1, 20 records): onlyoutfit,skin_color,hair_style,emoteandhair_colorrecords, and no[int, array]pair at any depth. Its only consumer resolver isMathResolver(Catalog/CatalogAssembly.cs:6). CultLib's ownGameCult.NetworkingMessageunion (Messages.cs:10) is a CultNet wire message, decoded withMessageSerialization.Options(MessageSerialization.cs:7,DefaultOptions), outsideOptionsFor: the arm guard does not reach it (follow-up). - C4. Python and Kotlin: no union-shaped record. repixelizer uses
define_database_entry_typewith flat slots;cultmesh-kotlindocuments aredata classes with no sealed type.
So the stores the arm refusal can affect today are Aetheria's (A2-A4, none carrying an unknown arm) and StreamPixels' overlay catalog (C3, carrying no arm). Every other runtime's persisted enums are serde shapes that the opt-in derive leaves alone.
Rationale
- Why a guard formatter, not a fork or a re-implementation. U5 shows a guard that only checks the key and the
subtype and delegates the encoding is enough. It keeps MessagePack-CSharp's union encoding as the one encoder,
costs one small file, and covers every path that already goes through
OptionsFor. Forking the generated template or vendoringDynamicUnionResolverwould put a second encoder in CultLib. - Why the Rust derive is opt-in. Serde enums already persisted by Rust consumers keep their bytes
(
docs/persisted-record-encoding.md); a changed default would make every such store unreadable or non-canonical. A new derive with explicit arm keys is how a Rust type says "this crosses runtimes". - Why arms use newtype variants over slot structs. A C# arm is a subclass whose slots include the base class's
keys. A Rust newtype variant over a struct with keyed slots is the same thing, and it needs the slot code the
DatabaseEntryderive already has, factored so both derives share one slot generator. - Why the slot rule is a separate, asked cut. The ruling settles arms and asks for extra trailing slots to be "settled against the same re-encode rule". Two written contracts disagree (U10), and the current behaviour drops data silently (U6), so the choice is the operator's and it should change every runtime at once.
- The recommendation on extra slots is refusal. An extra trailing slot is the slot-level twin of an unknown
arm: data from a newer writer that the reader's types do not describe. The operator ruled refusal for arms
knowing it costs an older reader the newer record; the same reasoning gives refusal for slots, and record
envelope slots are already refused in every runtime (U10). The alternative with precedent is
variants:ruling:undeclared-read-only(a record a type can read but does not declare loads read-only): it would let an older reader see a newer record without overwriting it, but only where a catalog shows the extra slot (top level), while most evolution here is nested (A3'sBossslot sits inside a union arm). - Missing trailing slots stay tolerated. The reader knows a missing slot and its default, so it carries no bytes it does not understand, and Aetheria's live run save depends on it (A3). A store whose guard compares re-encoded bytes (Idunn's) refuses such a record by its own stricter rule, which is that guard's purpose.