diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 65e3a5af..5ac6c3b5 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -206,8 +206,10 @@ A record whose payload carries a slot past the slots its reader declares, at any element, union arm, unit arm), is read-only the same way, whatever id it is stored under. The one consent is the declaration above, and it reaches only depth-0 slots the schema comparison reports ignored under a listed compatible id, up to the highest slot it reports: a mark anywhere else, past that slot, or at depth 0 -under the reader's own id, is not consented to. Declared slots are what the type's own formatter writes; a -type with its own `[MessagePackFormatter]`, or one no instance of which can be written, is not checked. +under the reader's own id, is not consented to. Declared slots are what the type's own formatter writes; only +a type MessagePack writes by its `[Key]` layout is checked, and one no instance of which can be written is not +(`cultlib-gaps:ruling:slot-unmeasurable-type`; see the schema-compatibility contract for both exemptions and +for the user code the measure runs). A flatten of a variant whose base chain holds a record that is not owned is refused with that record's refusal, because it would write the base's decoded view; the variant's own writes persist only its delta and stay allowed. A plain write to another key of a value read from a read-only record is a copy this rule does diff --git a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md index 894865c9..c99d6292 100644 --- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md +++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md @@ -123,12 +123,22 @@ reports ignored under that id, and only up to the highest slot it reports: a pay one is not consented to. Any other mark (nested, list element, arm, unit arm, or depth 0 under the reader's own id) keeps the record read-only whatever is declared. -A type's declared slots are the array its own formatter writes for one instance (an uninitialized instance, -else one from a parameterless constructor), never a reflected member list, so a non-public `[Key]` member of -an `AllowPrivate` type counts. Only concrete int-keyed `[MessagePackObject]` types are checked. A type that -names its own `[MessagePackFormatter]` owns its layout and its tolerance and is not checked, and neither is a -type no instance of which can be written (no parameterless constructor and a `[Key]` getter that throws on an -uninitialized one): extra slots in such a type are read past and dropped on the next write, as before. +A type's declared slots are the array its own formatter writes for one instance, never a reflected member +list, so a non-public `[Key]` member of an `AllowPrivate` type counts. Only a concrete int-keyed +`[MessagePackObject]` type whose formatter is MessagePack's own `[Key]`-layout formatter is checked. Not +checked, and so read past with any extra slots dropped on the next write, as before: + +- a type formatted by anything else: its own `[MessagePackFormatter]`, a `CultCacheFormatterResolver` (or any + resolver ahead of MessagePack's), or the collection formatter of a `[MessagePackObject]` collection type + such as a `List` subclass. Such a formatter owns its layout and its tolerance; +- a type no instance of which can be written: no write of an uninitialized instance and no write of one + from a parameterless constructor succeeds (for example a `[Key]` getter that throws on an uninitialized + instance and no parameterless constructor). This gap is ruled: `cultlib-gaps:ruling:slot-unmeasurable-type`. + +Measuring runs user code once per type per process, on the first read of the type in a store load: the +type's `[Key]` getters on an uninitialized instance and, if that write throws, its parameterless +constructor (public or not) and the getters on that instance. Any failure of both leaves the type unchecked +for the rest of the process, and nothing else: it does not fail the read. `Flatten` of a variant writes its base chain's decoded view into a plain record, so it is refused when any record on that chain is not owned by its home store (a mark nothing explains, or undeclared drift that drops diff --git a/tests/vectors/union-parity/README.md b/tests/vectors/union-parity/README.md index 9ecf47f5..94597ed2 100644 --- a/tests/vectors/union-parity/README.md +++ b/tests/vectors/union-parity/README.md @@ -57,6 +57,12 @@ from each. The fifth is a bare arm of union `UnitOutcome` (0 Waiting `[Message]` An object's declared slots are the slots its type's own formatter writes for one instance (for these fixtures, the highest key plus one); a reader derives them from the layout it writes, never from a list of public members. +Only a type the runtime writes by its own positional `[Key]` layout is checked. Exempt, as in the C# reference +(`cultlib-gaps:ruling:leaf-acceptance-follows-reference`): a type the runtime formats some other way (a +user codec or formatter, a hand-written serializer, a resolver ahead of the runtime's own, a collection type +written as its items), and a type no instance of which the runtime can write for measuring +(`cultlib-gaps:ruling:slot-unmeasurable-type`). Neither is ever marked. No vector carries an exempt type: each +runtime pins its own exemption with its own fixture. Only an array longer than that marks: a short array (missing trailing slots) keeps the defaults and is writable, and a non-nil value in a retired slot below the highest key (Image's slot 2) does not mark. A union adds no depth: an arm's array is counted against the arm's own slots. @@ -104,7 +110,7 @@ Two single-file stores, each record written as a value and its payload then repl (schema `tests.slot_unit.v1`, a document `[Name, Outcome: UnitOutcome]`). `marked` is `extra-slot-nested-record.msgpack`; `clean` is `union-surface.msgpack`; `unit-arm` is the `tests.slot_unit` document `["unit-arm", [2, ["extra-slot"]]]`, whose Denied2 arm sits at depth 1 (the document is depth 0; a union - adds none). + adds none); `marked-variant` is a variant of `marked` overriding only `Id` (slot 0) to `"marked-variant"`. - `extra-slot-consent.cc`: every record is stored under `tests.union_surface.wide`, whose catalog entry is `tests.union_surface`'s seven members plus slot 7 (`Wider`). Read it with a `tests.union_surface` type of schema `tests.union_surface.v2` (the reference names it `SurfaceDeclaring`) that lists `tests.union_surface.wide` @@ -113,12 +119,15 @@ Two single-file stores, each record written as a value and its payload then repl `extra-slot-nested-record.msgpack` plus one root slot; `clean` is `union-surface.msgpack`. `slot-outcomes.msgpack` is a second outcomes file, as `bounds-outcomes.msgpack` is, so readers of `outcomes.msgpack` -stay green. It is an array of rows `[store, key, writable, explained, type, slots, declared, occurrences]`, one -per record of both stores: `writable` is what `IsWritable` answers; for an unmarked record the last five are nil, -otherwise they are the mark: whether it is explained, the first type read with extra slots, the slots it carried, +stay green. It is an array of rows `[store, key, writable, explained, type, slots, declared, occurrences, flattenRefusedBy]`, +one per record of both stores: `writable` is what `IsWritable` answers; `flattenRefusedBy` is nil unless the record +is a variant whose flatten is refused, and then it is the key of the base-chain record whose own refusal refuses +it (the refusal names that key; the store is unchanged). For an unmarked record `explained` through `occurrences` +are nil, otherwise they are the mark: whether it is explained, the first type read with extra slots, the slots it carried, the slots the reader declares, and how many objects carried extra slots. A depth-0 type is the reader's document type (the reference's names: `SurfaceDeclaring` for the consent store). For each row, open a copy of the store, -assert the mark and `IsWritable`, then remove the key: refused (with the mark, the file unchanged) exactly where +assert the mark and `IsWritable`, flatten the key when `flattenRefusedBy` is set and assert the refusal, then +remove the key: refused (with the mark, the file unchanged) exactly where `writable` is false. In the consent store a write of `consented` lands and a write of `over-consent` is refused. The carried-verbatim check: open `extra-slot-store.cc`, assert `marked` read-only and `clean` writable, write diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index 49fcc5ec..765d910a 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -10,7 +10,7 @@ All notable changes to this package are documented in this file. - A record whose payload carries a slot past the slots its reading type declares, at any depth (the record, a nested object, a list element, a union arm or a unit arm), is loaded read-only: it is served, and every staged write or removal of its key (`Upsert`, `UpsertVariant`, `Remove`, a batch, and `Flatten` of a variant) is refused with `CultSchemaConflictException` before anything is staged, and the store keeps the record byte for byte. Before, the extra slots were skipped on read and dropped on the next write. Declaring an id compatible consents only to depth-0 slots the schema comparison reports ignored under that id; any other extra slot keeps the record read-only whatever is declared, including under the reader's own id. A variant overriding a slot its type does not have is read-only too (it used to be shed on flush). Write such a record from the build that declares those slots. Missing trailing slots still default and stay writable. - `Flatten` (and `FlattenAsync`) of a variant whose base chain holds a record its store does not own (a record held read-only for extra slots, or undeclared drift that drops slots) is refused with `CultSchemaConflictException` naming that base record, and the store is left as it was. Before, the flatten wrote the base's decoded view into a plain record and the base's unread data was lost for that variant. Writing and removing the variant itself are still accepted: they persist only its delta. - `MintElementIds` refuses a home store's commit while that store holds a record read-only for extra slots whose element ids exist only in memory, until the build that declares those slots rewrites it; commits to earlier stores stand. -- A type's declared slots are the array its own formatter writes for one instance, not its public `[Key]` members, so a non-public `[Key]` of an `AllowPrivate` type counts. A type carrying `[MessagePackFormatter]` is not checked for extra slots (it owns its layout), and neither is a type no instance of which can be written (no parameterless constructor and a `[Key]` getter that throws on an uninitialized instance); extra slots in those are read past and dropped on the next write, as before. +- A type's declared slots are the array its own formatter writes for one instance, not its public `[Key]` members, so a non-public `[Key]` of an `AllowPrivate` type counts. Only a `[MessagePackObject]` type MessagePack writes by its `[Key]` layout is checked for extra slots. A type with another formatter (its own `[MessagePackFormatter]`, a `CultCacheFormatterResolver`, or the collection formatter of a `[MessagePackObject]` collection type) owns its layout and is not checked, and neither is a type no instance of which can be written (no parameterless constructor and a `[Key]` getter that throws on an uninitialized instance; ruled `cultlib-gaps:ruling:slot-unmeasurable-type`). Extra slots in those are read past and dropped on the next write, as before. Measuring runs the type's `[Key]` getters, and on failure its parameterless constructor, once per type per process on the first store read of it; a failure leaves the type unchecked. ### Added