From 19963293ae422ed1e54b6e319fe84b8e752d6110 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 00:54:39 +0200 Subject: [PATCH] Contracts: whole-value shapes, closed list of owned shapes, door only verifies Co-Authored-By: Claude Opus 5.5 --- .../Contracts/cultcache-persistence-format.md | 61 ++++++++++--------- .../Contracts/cultcache-store-composition.md | 18 +++--- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 81c5eef3..7afc1abe 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -408,12 +408,18 @@ fifth slot: ## Element Ids -Every object that sits in a list, array or dictionary value (`IDictionary<,>` or -`IReadOnlyDictionary<,>`) of a registered document type, or of a keyed struct a document -holds, union subtypes included, carries an id: one string member with an integer `[Key]` -and `[CultElementId]`. The id identifies the element inside its list, so an override can -name it (a path step is `[slot, elementId]`) and a base edit that reorders or inserts -elements retargets nothing. +Every object that sits in a list, array or dictionary value of a shape the element-id walk +owns carries an id: one string member with an integer `[Key]` and `[CultElementId]`. The +owned shapes are arrays, `IEnumerable` classes and interfaces, `IDictionary<,>` and +`IReadOnlyDictionary<,>` values, and `[MessagePackObject]` or `[Union]` types with integer +keys (a keyed struct included), read through the members MessagePack writes. Union subtypes +are included. The id identifies the element inside its list, so an override can name it (a +path step is `[slot, elementId]`) and a base edit that reorders or inserts elements +retargets nothing. + +Any other shape is one whole value. Its elements carry no element ids: a `[CultElementId]` +member inside it is a plain string, never minted or checked and never counted for `v3`. A +variant replaces it whole (operator, 2026-10-16). - A random id is 12 lowercase hex characters. A derived id (`[CultElementId(nameof(Source))]`, for content-addressed elements) is the @@ -432,28 +438,27 @@ elements retargets nothing. Python and Rust read `v3` and keep a `v3` header they found when they rewrite a store; they carry ids and do not decide when a document carries them. - An object list's elements are written by their `[Key]` layout, because the id is a - keyed member of the element. A list whose element type is not keyed - (`[MessagePackObject]` or `[Union]`) is a value list and is replaced whole; so is a list - of keyed structs, since a struct is a value and never an element, but an object list - inside a keyed struct is walked like any other. The walk follows declared member types: - a member declared `object` is untyped on disk, and a subclass's extra members under a - non-union base-typed member are not written, so neither is walked. The walk reads the - members MessagePack writes: an integer `[Key]`, no `[IgnoreMember]`, a public field or a - property whose getter is public. Any other `[Key]` member on the way to an object list - (non-public, or a property with a non-public getter) is refused at registration, because - MessagePack writes it only under `AllowPrivate` and the element-id walk reads public - members. An id member needs a public setter or `init`, so the id MessagePack writes is read - back; one with a non-public setter, or one MessagePack does not write, is refused. A - `[MessagePackObject]` type is read by key even when it is `IEnumerable`; MessagePack - writes a keyed type that is also a collection (`ICollection`, `IList`, `IDictionary`) - as a collection, so C# refuses such a type at the first encode or decode when it is on the - path to an object list. A `[DataContract]` type the walk reads as a collection, which - MessagePack writes by its members, is refused the same way. A formatter MessagePack's - source generator emits is not refused. For an - object list, C# refuses at registration a concrete element type that is not keyed and - `[MessagePackFormatter]` on any type, collection type or member on the way to the list or on the id - member, and refuses at the first encode or decode of the document a formatter - resolver that claims one of those types. + keyed member of the element. Value lists are replaced whole: a list of a non-keyed + class or struct, or of primitives. So is a list of keyed structs, since a struct is a + value and never an element, but an object list inside a keyed struct is walked like any + other. The walk follows declared member types: a member declared `object` is untyped on + disk, and a subclass's extra members under a non-union base-typed member are not written, + so neither is walked. Whole values are `ImmutableArray`, `ArraySegment`, + `Memory`, `KeyValuePair`, tuples, `Lazy`, a class or struct without + `[MessagePackObject]`, a `[DataContract]` type, a string-keyed type, a type or member + declaring `[MessagePackFormatter]`, and a non-public member of a type without + `AllowPrivate`. The members read are an integer `[Key]` with no `[IgnoreMember]`: a + public field or a property whose getter is public, or any visibility up the base chain + under the most derived `[MessagePackObject(AllowPrivate = true)]` declaration. An id member + needs a setter MessagePack calls (public or `init`; any under `AllowPrivate`), so the id + MessagePack writes is read back. Still refused: a bad id member (missing, duplicate, not + a string, no integer `[Key]`, `[MessagePackFormatter]` on it, one MessagePack does not + write or cannot assign, a key shared with another member, a bad derived source), a + concrete union arm that is not keyed, a keyed type that is also a collection + (`ICollection`, `IList`, `IDictionary`: MessagePack writes it as a collection, so C# + refuses it at the first encode or decode when it is on the path to an object list), a + type MessagePack has no formatter for, and a formatter resolver that claims a type on the + path. A formatter MessagePack's source generator emits is not refused. ## Canonical Semantic Schema Hash diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 60b5c4ca..15874f3d 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -300,17 +300,19 @@ exclusive open of the same lock file exclude each other is not established. Those are written by their `[Key]` layout so each element's id reaches disk, and the codec refuses the document on first encode or decode, naming the resolver and the type. The formatter `StandardResolver` or `StandardResolverAllowPrivate` hands out for a - type does not claim it (registration refuses a non-public `[Key]` member on the way to - an object list, so the private-member formatter writes every id). A value list (`List`, a list of a non-keyed class) is not on the + type does not claim it: the extra non-public members the `AllowPrivate` formatter writes + are whole values the walk does not own. A type or member declaring + `[MessagePackFormatter]` is a whole value off the path, the supported way to give a type its + own formatter. A value list (`List`, a list of a non-keyed class) is not on the path, so a resolver may claim it. - The codec also checks, for every type on that path, that `StandardResolver` does not write it against the walk. A type read by key may not be written by the generic collection resolver - (a `[MessagePackObject]` class that is also an `ICollection` or dictionary), and a type - read as a collection may not be written by the object resolver (a `[DataContract]` - enumerable); either is refused on first encode or decode, naming the type. Any other - formatter, such as one MessagePack's source generator emits, passes: the walk decides which - types carry ids. It also refuses, typed, a type MessagePack cannot build a formatter for, and a - resolver that throws while asked about one. + (a `[MessagePackObject]` class that is also an `ICollection` or dictionary); it is + refused on first encode or decode, naming the type. Any other formatter, such as one + MessagePack's source generator emits, passes: the walk decides which types carry ids and + the door only verifies. It also refuses, typed, a type MessagePack cannot build a formatter + for (it throws, or `StandardResolver` returns none), and a resolver that throws while asked + about one. - Deserialization runs under untrusted-data security. `CultRecordRef` is a permitted dictionary key, compared by its key string.