A resolver that hands out MessagePack's own [Key] formatter exempts nothing: contract, CHANGELOG, README, test

Soul s3 exemption-bullet-overstates-resolver. AnExtraSlotPastTheWrittenCountStillMarks pins it:
RelayResolver hands out StandardResolver's formatter for Relayed, and its extra slot marks 2/1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-12 01:51:03 +00:00
commit b4d9a38d3d
5 changed files with 37 additions and 6 deletions

View file

@ -128,9 +128,11 @@ list, so a non-public `[Key]` member of an `AllowPrivate` type counts. Only a co
`[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<T>` subclass. Such a formatter owns its layout and its tolerance;
- a type formatted by anything else: its own `[MessagePackFormatter]`, a formatter of its own that a
`CultCacheFormatterResolver` (or any resolver ahead of MessagePack's) hands out, or the collection formatter
of a `[MessagePackObject]` collection type such as a `List<T>` subclass. Such a formatter owns its layout and
its tolerance. A resolver that hands out MessagePack's own `[Key]`-layout formatter (StandardResolver's, or
StandardResolverAllowPrivate's) does not exempt the type: it is checked and marks;
- 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`.

View file

@ -11,6 +11,7 @@ using MessagePack.Formatters;
[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ListClaimResolver))]
[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.FbLaterClaimResolver))]
[assembly: CultCacheFormatterResolver(typeof(SlotReadOnlyTests.ResolvedBagResolver))]
[assembly: CultCacheFormatterResolver(typeof(SlotReadOnlyTests.RelayResolver))]
namespace GameCult.Caching.Tests
{

View file

@ -404,6 +404,21 @@ namespace GameCult.Caching.Tests
}
}
// A type an assembly resolver claims by handing out MessagePack's own [Key]-layout formatter: still measured.
[MessagePackObject]
public sealed class Relayed
{
[Key(0)] public string A = string.Empty;
}
public sealed class RelayResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new RelayResolver();
public IMessagePackFormatter<T>? GetFormatter<T>() =>
typeof(T) == typeof(Relayed) ? global::MessagePack.Resolvers.StandardResolver.Instance.GetFormatter<T>() : null;
}
// A [MessagePackObject] collection: MessagePack writes it as its items, not by [Key].
[MessagePackObject]
public class TagList : List<string> { }
@ -419,6 +434,7 @@ namespace GameCult.Caching.Tests
[Key(4)] public Bag? Bag;
[Key(5)] public ResolvedBag? Resolved;
[Key(6)] public TagList? Tags;
[Key(7)] public Relayed? Relayed;
}
private static readonly CultDocumentRegistry MeasuredRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(MeasuredDoc) });
@ -483,6 +499,17 @@ namespace GameCult.Caching.Tests
path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, countedPayload, "counted.cc");
using (var cache = Open(path, MeasuredRegistry))
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "measured through its constructor", nameof(Counted), 2, 1);
// A resolver that hands out MessagePack's own [Key] formatter exempts nothing.
var relayedPayload = Emit((ref MessagePackWriter w) =>
{
w.WriteArrayHeader(8); w.Write("r");
for (var i = 0; i < 6; i++) w.WriteNil();
w.WriteArrayHeader(2); w.Write("a"); w.Write(Extra);
});
path = Seed(MeasuredRegistry, typeof(MeasuredDoc), () => new MeasuredDoc { Name = "c" }, relayedPayload, "relayed.cc");
using (var cache = Open(path, MeasuredRegistry))
AssertSlotRefusal(() => cache.Commit(batch => batch.Remove(Marked)), "relayed by a consumer resolver", nameof(Relayed), 2, 1);
}
// ---- a flatten writes its base chain's decoded view: refused when a record on the chain is not owned ----

View file

@ -59,8 +59,9 @@ An object's declared slots are the slots its type's own formatter writes for one
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
user codec or formatter, a hand-written serializer, a resolver ahead of the runtime's own that supplies a layout
of its own, a collection type written as its items; a resolver that hands out the runtime's own positional
layout exempts nothing), 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

View file

@ -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. 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.
- 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 formatter of its own from a `CultCacheFormatterResolver`, or the collection formatter of a `[MessagePackObject]` collection type) owns its layout and is not checked; a resolver that hands out MessagePack's own `[Key]`-layout formatter does not exempt the type, 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