CultCache: a record whose payload carries slots past its reader's, at any depth, is held read-only

The guard formatter counts each int-keyed object's slots into a thread-bound scope opened around the one payload decode;
ToStoredDocument turns the finding into CultStoredDocument.UnreadSlots (explained only for depth-0 slots the catalog comparison
reports ignored); HeldOwned refuses an unexplained mark whatever is declared; RefuseUndeclared throws the one refusal with
UnreadSlots; CultCache.IsWritable asks the same predicate. Vectors: extra-slot-unit-arm, extra-slot-store.cc.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-11-10 14:35:10 +00:00 • committed by Libby
commit c55d34fb32
14 changed files with 642 additions and 30 deletions

View file

@ -166,7 +166,7 @@ public static class CultDocumentMessagePackSerialization
private static (MessagePackSerializerOptions Options, IFormatterResolver[] Ahead) Compose(IFormatterResolver[] consumerResolvers)
{
// The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer
// The guard goes first and wraps whatever formatter the rest of the composite would hand out (a union, or a concrete int-keyed object for its slot count), so a consumer
// resolver still encodes an arm while the key check runs.
var ahead = consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(CultMathExprResolver.Instance).ToArray();
var inner = CompositeResolver.Create(ahead.Append(StandardResolver.Instance).ToArray());
@ -214,6 +214,19 @@ public static class CultDocumentMessagePackSerialization
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
}
// The same decode, with the slots it read past noted (CultSlotScope). Opened around the one decode and after the depth check,
// so a payload past the nesting limit is refused, never marked.
internal static CultDecoded DeserializeMarked(Type type, byte[] payload, CultDocumentRegistry registry)
{
if (registry == null) throw new ArgumentNullException(nameof(registry));
registry.GetRequired(type);
CultPayloadDepth.Check(payload, type, PayloadDirection.Read);
using var scope = CultSlotScope.Open();
var document = MessagePackSerializer.Deserialize(type, payload, OptionsForDocument(type))
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
return new CultDecoded(document, scope.Finding());
}
/// <summary>
/// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member
/// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil.
@ -823,4 +836,7 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{
return CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
}
internal override CultDecoded DeserializeMarked(Type documentType, byte[] payload) =>
CultDocumentMessagePackSerialization.DeserializeMarked(documentType, payload, Registry);
}