cultlib-gaps: slot read-only probe (any depth), section S of the union parity map

Probe facts for ruling cultlib-gaps:ruling:extra-trailing-slots: the C# skip site and why a
resolver guard sees every nested object and arm, the mark arriving at depth 0, 1, 2, in an arm
and in a list element in C#, Rust, Python and TS, the cost on Aetheria.cc against an A/A
control, a live nested case in Aetheria (WeaponData.Tracking), the C# whole-view flush that
re-encodes every held record, and Kotlin's missing library slot codec. Cut order step 4 and
the model page follow the ruling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 21:41:45 +02:00
commit 286e8a71e9

View file

@ -21,8 +21,10 @@ committed. Earlier evidence is not repeated here: Eve `docs/thing2-map.md` P12-P
3. `union-derive-rs` and `union-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-py` follows the same golden for cultcache-py.
4. `slot-tolerance`: extra trailing slots, per `cultlib-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.
4. `slot-tolerance` (C# reference), then `slot-readonly-parity` (Rust, TypeScript, Python): a record carrying a
slot past its reader's declared slots, at any depth, is read-only (ruling
`cultlib-gaps:ruling:extra-trailing-slots`; the refinement is settled by section S). Both change a slot rule the
cuts in step 3 build, so they wait for 1-3 rather than reopening them.
5. `union-parity-release`: changelogs and tags, Breaking where `docs/semver-policy.md` says so.
Thing2's `cut-contract-rs` waits on step 3 (Rust) only: its first step pins cultcache-rs at the commit that lands
@ -38,7 +40,7 @@ ruled and slots are not, so the slot rule is its own cut rather than a blocked s
|---|---|---|---|
| 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`). |
| 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, at any depth:** the record is read and is read-only; every write or removal of it is refused, and it is persisted from its original bytes (ruling, S). | 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)
@ -106,6 +108,73 @@ ruled and slots are not, so the slot rule is its own cut rather than a blocked s
`packages/cultcache-ts/test/cult-cache.test.ts` and `packages/cultcache-py/tests/test_cultcache.py`. The CultNet
precedent for a reference-written vector is `CULTNET_WRITE_VECTORS=1` (`docs/cultnet-error-contract-cut.md:170`).
## S. Slot read-only probe (any depth)
Imagination pass `imagination-cl-slot-readonly` (session `self-2026-10-10c-eureka`), under ruling
`cultlib-gaps:ruling:extra-trailing-slots` ("b, and probe the any-depth refinement"). Probes live under the session
scratchpad, `imagination-cl-slot-readonly/` (`cs` C# synthetic, `aeth` C# on Aetheria's game database, `rs`, `py`,
`ts`, `il` decompiled MessagePack-CSharp 3.1.7). None is committed. The ruling's test: every runtime's decoder
carries a "slot skipped" mark to the record cheaply, on the one decode path, with no second pass and no per-type
consumer code.
- **S1. Where C# skips, and why a resolver sees it.**
`DynamicObjectTypeBuilder.BuildDeserializeInternalDeserializeLoopIntKey` emits the slot loop's `switch` default as
a direct `MessagePackReader.Skip()` call; the source generator's `FormatterTemplate` writes
`default: reader.Skip(); break;`. `MessagePackReader` is a struct, and neither path raises an event or reads an
option, so the skip itself cannot be observed without a fork. Both fetch every member formatter from
`options.Resolver` (the generated code's `formatterResolver = options.Resolver`), and the union formatters fetch arm
formatters the same way (U1). A resolver ahead of the composite is therefore handed every nested object, list
element and union arm, and can read each one's array header before delegating.
- **S2. The mark arrives in C#.** Probe `cs`: a guard resolver returns, for each concrete int-keyed
`[MessagePackObject]` type, a formatter that reads the array count (from `NextCode` for a fixarray, a peek for
array16 and array32), compares it with the highest `[Key]` plus one (base keys included, MessagePack's member
visibility rule), notes a skip into a `[ThreadStatic]` scope the decode's caller opened, and delegates to the
formatter the rest of the composite hands out. An extra slot at depth 0 (record), depth 1 (nested object), depth 2
(object in an object), in a union arm and in a list element each arrives as one mark naming the type and both
counts; the clean payload gives none. The guard needs `CompositeResolver.Create` around it so formatters are
cached (uncached, it was 40 times slower).
- **S3. C# cost.** Aetheria `GameData/Aetheria.cc` (12.1 MB; 237 of 238 records decodable with Aetheria.Shared's
types; decoded with `OptionsFor` that assembly; best of 9, three rounds): plain 165-209 ms, a second plain composite
(A/A) 174-193 ms, guarded 179-196 ms. That is 3.7-8.9% over the best plain run, inside the A/A spread. Worst case,
synthetic tiny objects (120,000 records, 23.1 MB, about 24 objects each): plain 618-637 ms with A/A within 4%,
guarded 704-705 ms: 11-19%, about 30 ns per decoded object.
- **S4. A live case in Aetheria.** The same probe, run with the AetherDb release binaries (built 2026-09-30) against
today's `Aetheria.cc`, marks 25 records: `WeaponItemData` whose `BehaviorData` arms (`AutoWeaponData`,
`ChargedWeaponData` and others) carry 34 slots where that build declares 21 or 33. The extra slot is
`WeaponData.Tracking` (key 33, Aetheria `8cd71ed6`, 2026-10-07): inside a union arm, inside a list. Today that tool
reads these records and drops `Tracking` from all 25 at its next landing (S5). Refusing nested extra slots would
stop it opening `Aetheria.cc` at all; read-only lets it open the store and refuses writes to those 25.
- **S5. A C# flush re-encodes every held record, not only the edited one.** `PushAll` and an unconditional
`CommitBatch` write `ToPersistedRecord(entry, SerializePayload)` for every entry (`CultCache.cs:3191-3201`, `3355`,
`3384`), under the local descriptor's schema id and catalog entry. So U6's loss reaches every held record with
unread slots at the next flush of anything. Rust (holds payload bytes and decodes on `get`, `lib.rs:2280-2293`),
Python (holds envelopes and decodes at load, `cache.py:257-285`) and TypeScript (holds payload bytes) carry an
untouched record's bytes. In C#, read-only therefore also means persisted from the original record (bytes, schema
id, catalog entry), never from the decoded document.
- **S6. Rust.** Probe `rs`: the derive's slot loop, with the trailing `IgnoredAny` loop counting into a
`thread_local`, marks depth 1, depth 2 and a list element. Depth 0 is the `DatabaseEntry` derive's own trailing
loop (`cultcache-rs-derive/src/lib.rs:258`), the same edit. A plain serde struct nested in a record already refuses
an extra element (`array had incorrect length, expected 2`), so only derived slot types skip. Cost (120,000
records, 21.4 MB): inside the A/A spread (A/A from -3% to +9%; marked against plain from -14% to +13% over six
runs). The added code is one branch per object, never taken on a clean payload. Rust decodes on `get`, so whether
a held record is writable is answered by decoding its held bytes through the same derive, on the write path only.
- **S7. Python and TypeScript.** Probes `py` and `ts`: a slot codec tree passing a context argument (the shape
`union-codec-py` and `union-codec-ts` build) marks all five positions. Python (40,000 records, 11.1 MB, msgpack
unpack included): +14% against an A/A spread of 8%. TypeScript (120,000 decoded records, codec walk only; no
msgpack in the scratch copy): +20% against an A/A spread of 18%. The check is one length comparison per object.
- **S8. Kotlin has no library slot codec.** `CultDocumentCodec<T>.decode` is written per type by the consumer
(`packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt:31-36`), and the package's own Eve
decoders skip extras by hand at 8 sites (`repeat((count - N).coerceAtLeast(0)) { reader.skip() }` in
`eve/EveDocuments.kt`). No slot rule, refusal or read-only, can be held by the library there until it owns slot
decoding. That is a follow-up, not a per-runtime rule.
- **S9. CultMesh decode paths** (`CultMesh.cs:2779,3493,3499`, `CultMeshPrimitives.cs:1158,1230,2357`) go through
`OptionsFor`, so through the guard, with no scope open: unchanged. Whether a Mesh replica that re-publishes a
decoded document drops slots is a follow-up.
- **S10. The refinement is in.** Every runtime with a library slot decoder (C#, Rust, Python, and the TypeScript
codec being built) carries the mark to the record on its one decode path, with no second pass and no consumer
code. The cost on Aetheria's real store is inside the A/A noise (S3); the worst synthetic case is 11-19% in C#.
A nested extra slot makes the record read-only; it is not refused.
## A. Aetheria (C#, the only consumer persisting `[Union]`s)
Aetheria consumes the reference through Unity packages pinned by tag (`Packages/manifest.json:55-57`:
@ -195,7 +264,7 @@ the opt-in derive leaves alone.
- **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
- **The recommendation on extra slots was refusal; the ruling chose read-only, and S settles its depth.** 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