2026-05-08 01:13:15 +01:00
using System ;
2026-09-29 22:53:13 +02:00
using System.IO ;
2026-05-08 02:31:06 +01:00
using System.Buffers ;
2026-09-13 21:12:53 +02:00
using System.Collections.Concurrent ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
using System.Collections.Generic ;
2026-09-13 21:12:53 +02:00
using System.Linq ;
using System.Reflection ;
2026-05-08 01:13:15 +01:00
using GameCult.Caching ;
using MessagePack ;
using MessagePack.Formatters ;
using MessagePack.Resolvers ;
namespace GameCult.Caching.MessagePack ;
public sealed class CultDocumentResolver : IFormatterResolver
{
public static readonly CultDocumentResolver Instance = new ( ) ;
private CultDocumentResolver ( ) { }
public IMessagePackFormatter < T > ? GetFormatter < T > ( )
{
var type = typeof ( T ) ;
if ( type . IsGenericType & & type . GetGenericTypeDefinition ( ) = = typeof ( CultRecordRef < > ) )
{
2026-05-08 01:50:15 +01:00
var formatterType = typeof ( global :: GameCult . Caching . CultRecordRefFormatter < > ) . MakeGenericType ( type . GetGenericArguments ( ) [ 0 ] ) ;
2026-05-08 01:13:15 +01:00
return ( IMessagePackFormatter < T > ) Activator . CreateInstance ( formatterType ) ! ;
}
return null ;
}
}
public static class CultDocumentMessagePackSerialization
{
2026-05-08 02:31:06 +01:00
private const int PersistedRecordFieldCount = 4 ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
private const int VariantRecordFieldCount = 5 ;
2026-05-09 11:45:46 +01:00
private const int SchemaCatalogEntryFieldCount = 7 ;
private const int SchemaCatalogMemberFieldCount = 8 ;
2026-05-08 02:31:06 +01:00
private const int StoreSnapshotFieldCount = 3 ;
2026-10-14 21:52:36 +02:00
private static readonly ( MessagePackSerializerOptions Options , IFormatterResolver [ ] Ahead ) Plain = Compose ( Array . Empty < IFormatterResolver > ( ) ) ;
2026-09-13 21:12:53 +02:00
2026-10-14 21:52:36 +02:00
public static readonly MessagePackSerializerOptions Options = Plain . Options ;
2026-09-13 21:12:53 +02:00
2026-10-14 21:52:36 +02:00
// Per assembly: the options a document type's bytes are written with, and the resolvers composed ahead of
// StandardResolver, the ones that can claim a type before its [Key] layout does.
private static readonly ConcurrentDictionary < Assembly , ( MessagePackSerializerOptions Options , IFormatterResolver [ ] Ahead ) > AssemblyOptions = new ( ) ;
// Per document type: why its element ids cannot reach disk under the composed resolvers, or null. Types and resolvers
// are fixed for the process, so this is a cache of a pure answer.
private static readonly ConcurrentDictionary < Type , string? > DocumentProblems = new ( ) ;
private static readonly MethodInfo GetFormatterMethod = typeof ( IFormatterResolver ) . GetMethod ( nameof ( IFormatterResolver . GetFormatter ) ) ! ;
public static MessagePackSerializerOptions OptionsFor ( Assembly documentAssembly ) = > ComposedFor ( documentAssembly ) . Options ;
private static ( MessagePackSerializerOptions Options , IFormatterResolver [ ] Ahead ) ComposedFor ( Assembly documentAssembly )
2026-09-13 21:12:53 +02:00
{
if ( documentAssembly = = null ) throw new ArgumentNullException ( nameof ( documentAssembly ) ) ;
return AssemblyOptions . GetOrAdd ( documentAssembly , static assembly = >
{
var resolvers = assembly . GetCustomAttributes < CultCacheFormatterResolverAttribute > ( )
. Select ( attribute = > CreateResolver ( attribute . ResolverType ) )
. ToArray ( ) ;
2026-10-14 21:52:36 +02:00
return resolvers . Length = = 0 ? Plain : Compose ( resolvers ) ;
2026-09-13 21:12:53 +02:00
} ) ;
}
2026-10-14 21:52:36 +02:00
// The options a document's bytes are written and read with. A document's list elements carry their ids by their [Key]
// layout, so a resolver ahead of StandardResolver that formats a type on the way to those lists would write them
// without the id: refused here, once per document type, on every encode and decode path.
internal static MessagePackSerializerOptions OptionsForDocument ( Type documentType )
{
if ( documentType = = null ) throw new ArgumentNullException ( nameof ( documentType ) ) ;
var ( options , ahead ) = ComposedFor ( documentType . Assembly ) ;
var problem = DocumentProblems . GetOrAdd ( documentType , type = > ResolverClaim ( type , ahead ) ) ;
return problem = = null ? options : throw new InvalidOperationException ( problem ) ;
}
private static string? ResolverClaim ( Type documentType , IFormatterResolver [ ] ahead )
{
foreach ( var type in CultElementIds . Inspect ( documentType ) . IdPath )
{
var generic = GetFormatterMethod . MakeGenericMethod ( type ) ;
2026-10-15 00:26:53 +02:00
// A resolver that hands back MessagePack's own formatter for the type writes its [Key] layout: that is no claim, and the
2026-10-15 01:23:40 +02:00
// composite stops there, so no later resolver is asked. StandardResolverAllowPrivate's formatter writes the same layout
// plus non-public [Key] members, and registration refuses a non-public member on the way to an object list, so it
// claims nothing either.
2026-10-15 02:53:28 +02:00
object? standard ;
try
{
standard = generic . Invoke ( StandardResolver . Instance , null ) ;
}
catch ( Exception exception )
{
return $"Cult document {documentType.Name}: MessagePack cannot build a formatter for {type.Name}, which is on the path to a list whose elements carry ids ({(exception.InnerException ?? exception).Message})." ;
}
2026-10-15 20:52:13 +02:00
if ( WrittenAgainstWalk ( type , generic , standard ) )
2026-10-15 02:53:28 +02:00
{
2026-10-15 20:52:13 +02:00
return CultElementIds . WalksAsOwner ( type )
? $"Cult document {documentType.Name}: MessagePack writes {type.Name} as a collection, but the element-id walk reads it by its [Key] layout, so element ids would be lost or invented. A [MessagePackObject] type that is also a collection (ICollection<T>, IList, IDictionary) is written as a collection; make it one or the other."
: $"Cult document {documentType.Name}: MessagePack writes {type.Name} as an object, but the element-id walk reads it as a collection, so element ids would be lost or invented. A [DataContract] type that is also enumerable is written by its members; mark it [MessagePackObject] with integer [Key]s, or drop the contract." ;
2026-10-15 02:53:28 +02:00
}
2026-10-15 01:23:40 +02:00
var allowPrivateAsked = false ;
object? allowPrivate = null ;
2026-10-14 21:52:36 +02:00
foreach ( var resolver in ahead )
{
2026-10-15 02:53:28 +02:00
object? formatter ;
try
{
formatter = generic . Invoke ( resolver , null ) ;
}
catch ( Exception exception )
{
return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} failed to build a formatter for {type.Name}, which is on the path to a list whose elements carry ids ({(exception.InnerException ?? exception).Message}); a resolver may not throw for such a type." ;
}
2026-10-15 00:26:53 +02:00
if ( formatter = = null )
continue ;
if ( ReferenceEquals ( formatter , standard ) )
break ;
2026-10-15 01:23:40 +02:00
if ( ! allowPrivateAsked )
{
allowPrivateAsked = true ;
try
{
allowPrivate = generic . Invoke ( StandardResolverAllowPrivate . Instance , null ) ;
}
catch ( Exception )
{
allowPrivate = null ;
}
}
if ( allowPrivate ! = null & & ReferenceEquals ( formatter , allowPrivate ) )
break ;
2026-10-15 00:26:53 +02:00
return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} formats {type.Name}, which is on the path to a list whose elements carry ids, so its element ids would never reach disk. A resolver may not claim such a type; write it by its [Key] layout or keep it out of the document's lists." ;
2026-10-14 21:52:36 +02:00
}
}
return null ;
}
2026-10-15 20:52:13 +02:00
// The door refuses only a proven mismatch: StandardResolver's formatter being the very instance the contrary resolver's
// public singleton hands out (one is cached per type). An owner is contradicted by the generic resolver (a keyed
// ICollection<T> or dictionary), a collection by the object resolver (a [DataContract] enumerable). Union types walk as
// owners and the immutable and builtin resolvers claim only framework types, so neither can contradict. Any other
// formatter, a source-generated one among them, passes: the walk owns which types carry ids and this door is its safety net.
private static bool WrittenAgainstWalk ( Type type , MethodInfo generic , object? standard )
2026-10-15 02:53:28 +02:00
{
2026-10-15 20:52:13 +02:00
var contrary = CultElementIds . WalksAsOwner ( type ) ? ( IFormatterResolver ) DynamicGenericResolver . Instance : DynamicObjectResolver . Instance ;
return standard ! = null & & ReferenceEquals ( Formatter ( contrary , generic ) , standard ) ;
2026-10-15 02:53:28 +02:00
}
private static object? Formatter ( IFormatterResolver resolver , MethodInfo generic )
{
try
{
return generic . Invoke ( resolver , null ) ;
}
catch ( Exception )
{
return null ;
}
}
2026-10-14 21:52:36 +02:00
private static ( MessagePackSerializerOptions Options , IFormatterResolver [ ] Ahead ) Compose ( IFormatterResolver [ ] consumerResolvers )
2026-09-13 21:12:53 +02:00
{
2026-10-10 21:17:56 +02:00
// The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer
// resolver still encodes an arm while the key check runs.
2026-10-14 21:52:36 +02:00
var ahead = consumerResolvers . Append ( CultDocumentResolver . Instance ) . Append ( CultMathResolver . Instance ) . Append ( CultMathExprResolver . Instance ) . ToArray ( ) ;
var inner = CompositeResolver . Create ( ahead . Append ( StandardResolver . Instance ) . ToArray ( ) ) ;
var options = MessagePackSerializerOptions . Standard
2026-10-10 21:17:56 +02:00
. WithResolver ( CompositeResolver . Create ( new CultUnionGuard ( inner ) , inner ) )
2026-09-13 21:12:53 +02:00
. WithSecurity ( CultMessagePackSecurity . Instance ) ;
2026-10-14 21:52:36 +02:00
return ( options , ahead ) ;
2026-09-13 21:12:53 +02:00
}
private static IFormatterResolver CreateResolver ( Type resolverType )
{
var instance = resolverType . GetField ( "Instance" , BindingFlags . Public | BindingFlags . Static ) ? . GetValue ( null )
? ? resolverType . GetProperty ( "Instance" , BindingFlags . Public | BindingFlags . Static ) ? . GetValue ( null )
? ? ( resolverType . GetConstructor ( Type . EmptyTypes ) ! = null ? Activator . CreateInstance ( resolverType ) : null ) ;
return instance as IFormatterResolver
? ? throw new InvalidOperationException (
$"Formatter resolver {resolverType.FullName} needs a public static Instance or a public parameterless constructor and must implement {nameof(IFormatterResolver)}." ) ;
}
2026-05-08 01:13:15 +01:00
public static byte [ ] SerializeUntyped ( object value , Type type )
{
2026-07-20 01:48:56 +01:00
return SerializeUntyped ( value , type , CultDocumentRegistry . Shared ) ;
}
public static byte [ ] SerializeUntyped ( object value , Type type , CultDocumentRegistry registry )
{
if ( registry = = null ) throw new ArgumentNullException ( nameof ( registry ) ) ;
Delete the CultCache MessagePack source generator
The reflective CultDocumentRegistry is now the only descriptor authority,
and payloads always go through MessagePackSerializer with
OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed,
Unity always ran the reflective path, and no benchmark showed a gain,
while the generator duplicated the registry's shape rules.
Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution,
project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata
(provider attribute, interface, definitions, loader), the generated descriptor
and codec branches in CultCache and CultDocumentMessagePackSerialization,
DocumentShapeSweepTests and the generator half of the unkeyed test, the
Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo.
The registry rejects, with a message naming the fix, what MessagePackSerializer
cannot serialize and what the generator used to paper over: a document without
[MessagePackObject], a non-public document without AllowPrivate = true, and a
non-public member of an AllowPrivate document that is not [IgnoreMember].
DocumentShapeTests pins every rejection message and the accepted shapes'
round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin).
Rejected shapes are emitted into a dynamic assembly, which registry
discovery skips.
Nested member type names use reflection's Outer+Inner form, not the
generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned
(bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc
stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj
excluded) found no member type name naming a nested type in either form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
if ( value ! = null ) registry . GetRequired ( type ) ;
2026-10-14 21:52:36 +02:00
var payload = MessagePackSerializer . Serialize ( type , value , OptionsForDocument ( type ) ) ;
2026-10-11 08:33:15 +02:00
CultPayloadDepth . Check ( payload , type , PayloadDirection . Write ) ;
return payload ;
2026-05-08 01:13:15 +01:00
}
public static object DeserializeUntyped ( Type type , byte [ ] payload )
{
2026-07-20 01:48:56 +01:00
return DeserializeUntyped ( type , payload , CultDocumentRegistry . Shared ) ;
}
public static object DeserializeUntyped ( Type type , byte [ ] payload , CultDocumentRegistry registry )
{
if ( registry = = null ) throw new ArgumentNullException ( nameof ( registry ) ) ;
Delete the CultCache MessagePack source generator
The reflective CultDocumentRegistry is now the only descriptor authority,
and payloads always go through MessagePackSerializer with
OptionsFor(document assembly). No consumer builds AOT, IL2CPP or trimmed,
Unity always ran the reflective path, and no benchmark showed a gain,
while the generator duplicated the registry's shape rules.
Removed: GameCult.Caching.MessagePack.Generator and .Analyzers (solution,
project and analyzer references, NuGet pack list), CultGeneratedDocumentMetadata
(provider attribute, interface, definitions, loader), the generated descriptor
and codec branches in CultCache and CultDocumentMessagePackSerialization,
DocumentShapeSweepTests and the generator half of the unkeyed test, the
Microsoft.CodeAnalysis.CSharp test reference and InternalsVisibleTo.
The registry rejects, with a message naming the fix, what MessagePackSerializer
cannot serialize and what the generator used to paper over: a document without
[MessagePackObject], a non-public document without AllowPrivate = true, and a
non-public member of an AllowPrivate document that is not [IgnoreMember].
DocumentShapeTests pins every rejection message and the accepted shapes'
round trips against MessagePackSerializer bytes (keeps the 93A16E0305 pin).
Rejected shapes are emitted into a dynamic assembly, which registry
discovery skips.
Nested member type names use reflection's Outer+Inner form, not the
generator's dot form, so ReferenceHolderEntry's fixture id is re-pinned
(bd8506... -> 4ec0c5...). A scan of the schema catalogs in all 2786 .cc
stores under F:\Projects (AetheriaEve and CultLib-* worktrees, bin and obj
excluded) found no member type name naming a nested type in either form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00
registry . GetRequired ( type ) ;
2026-10-11 08:33:15 +02:00
CultPayloadDepth . Check ( payload , type , PayloadDirection . Read ) ;
2026-10-14 21:52:36 +02:00
return MessagePackSerializer . Deserialize ( type , payload , OptionsForDocument ( type ) )
2026-05-09 11:14:23 +01:00
? ? throw new InvalidOperationException ( $"MessagePack returned null for Cult document type {type.FullName}." ) ;
2026-05-08 01:13:15 +01:00
}
2026-05-08 02:31:06 +01:00
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
/// <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.
/// </summary>
public static byte [ ] OverlaySlots ( byte [ ] payload , IReadOnlyList < KeyValuePair < int , byte [ ] > > slots )
{
var reader = new MessagePackReader ( payload ) ;
if ( reader . NextMessagePackType ! = MessagePackType . Array )
{
throw new InvalidOperationException ( "A document payload is a MessagePack array; this one is not, so its slots cannot be overridden." ) ;
}
var count = reader . ReadArrayHeader ( ) ;
var elements = new List < byte [ ] > ( count ) ;
for ( var index = 0 ; index < count ; index + + )
{
var start = reader . Position ;
reader . Skip ( ) ;
elements . Add ( reader . Sequence . Slice ( start , reader . Position ) . ToArray ( ) ) ;
}
foreach ( var ( slot , value ) in slots )
{
while ( elements . Count < = slot )
{
elements . Add ( new byte [ ] { 0xc0 } ) ;
}
elements [ slot ] = value ;
}
2026-10-03 03:05:08 +02:00
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
var writer = new MessagePackWriter ( buffer ) ;
writer . WriteArrayHeader ( elements . Count ) ;
foreach ( var element in elements )
{
writer . WriteRaw ( element ) ;
}
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
2026-05-08 02:31:06 +01:00
public static byte [ ] SerializePersistedRecord ( CultPersistedRecord record )
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
WritePersistedRecord ( ref writer , record ) ;
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
public static CultPersistedRecord DeserializePersistedRecord ( byte [ ] payload )
{
var reader = new MessagePackReader ( payload ) ;
return ReadPersistedRecord ( ref reader ) ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
/// <summary>
/// Encodes the snapshot: every catalog entry and record from its fields, so what a caller edited is what is written.
/// </summary>
public static byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot ) = >
SerializeStore (
snapshot . FormatVersion ,
snapshot . SchemaCatalog . Select ( SerializeCatalogEntry ) . ToArray ( ) ,
snapshot . Records . Select ( SerializePersistedRecord ) . ToArray ( ) ) ;
// A store file from its parts, each already encoded: what a store carries forward is the bytes its file held, what it writes is
// an encoding of the object.
internal static byte [ ] SerializeStore ( string formatVersion , IReadOnlyList < byte [ ] > catalogEntries , IReadOnlyList < byte [ ] > records )
2026-05-08 02:31:06 +01:00
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
writer . WriteArrayHeader ( StoreSnapshotFieldCount ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
writer . Write ( formatVersion ) ;
writer . WriteArrayHeader ( catalogEntries . Count ) ;
foreach ( var entry in catalogEntries )
2026-05-08 02:31:06 +01:00
{
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
writer . WriteRaw ( new ReadOnlySpan < byte > ( entry ) ) ;
2026-05-08 02:31:06 +01:00
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
writer . WriteArrayHeader ( records . Count ) ;
foreach ( var record in records )
2026-05-08 02:31:06 +01:00
{
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
writer . WriteRaw ( new ReadOnlySpan < byte > ( record ) ) ;
2026-05-08 02:31:06 +01:00
}
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
2026-09-30 01:59:28 +02:00
/// <summary>
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
/// Reads a store file: exactly one complete MessagePack array of three slots (header, schema catalog, records), or an empty
/// array, which is an empty store. A truncated file, bytes after the array, a value that is not an array, a first slot that
2026-09-30 04:19:35 +02:00
/// is not a header string, a store with fewer or more slots, a record whose schema the catalog does not publish, or a body
/// that does not decode are refused with
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
/// <see cref="CultStoreUnreadableException"/>, so a reader never takes part of a file for the whole and never skips a slot
/// it does not understand. The rewriting paths use this same reader as their verdict on whether a file may be replaced.
2026-09-30 01:59:28 +02:00
/// </summary>
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
public static CultPersistedStoreSnapshot DeserializeSnapshot ( byte [ ] payload ) = > ReadStore ( payload ) . Snapshot ;
// The reader behind DeserializeSnapshot, and beside the snapshot the exact bytes the file held for each catalog entry and each
// record (parallel to the snapshot's arrays). The snapshot's objects hold no bytes: a store that carries entries forward keeps
// these slices itself.
internal static ( CultPersistedStoreSnapshot Snapshot , byte [ ] [ ] CatalogBytes , byte [ ] [ ] RecordBytes ) ReadStore ( byte [ ] payload )
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
{
try
2026-05-08 02:31:06 +01:00
{
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
RequireOneArray ( payload ) ;
var reader = new MessagePackReader ( payload ) ;
var fieldCount = reader . ReadArrayHeader ( ) ;
if ( fieldCount ! = 0 & & fieldCount ! = StoreSnapshotFieldCount )
{
throw new CultStoreUnreadableException (
$"The store has {fieldCount} top-level slots; this runtime reads {StoreSnapshotFieldCount} (header, schema catalog, records) or none." ) ;
}
var snapshot = new CultPersistedStoreSnapshot ( ) ;
if ( fieldCount = = 0 )
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
return ( snapshot , Array . Empty < byte [ ] > ( ) , Array . Empty < byte [ ] > ( ) ) ;
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
2026-09-29 02:04:33 +02:00
snapshot . FormatVersion = reader . ReadString ( )
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
? ? throw new CultStoreUnreadableException ( "Store snapshot declares no format version; this runtime reads " + CultPersistedStoreSnapshot . FormatV1 + ", " + CultPersistedStoreSnapshot . FormatV2 + " and " + CultPersistedStoreSnapshot . FormatV3 + "." ) ;
2026-05-08 02:31:06 +01:00
var catalogCount = reader . ReadArrayHeader ( ) ;
snapshot . SchemaCatalog = new CultSchemaCatalogEntry [ catalogCount ] ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var catalogBytes = new byte [ catalogCount ] [ ] ;
2026-05-08 02:31:06 +01:00
for ( var index = 0 ; index < catalogCount ; index + + )
{
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var start = reader . Position ;
2026-10-03 01:59:13 +02:00
snapshot . SchemaCatalog [ index ] = ReadSchemaCatalogEntry ( ref reader ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
catalogBytes [ index ] = reader . Sequence . Slice ( start , reader . Position ) . ToArray ( ) ;
2026-05-08 02:31:06 +01:00
}
var recordCount = reader . ReadArrayHeader ( ) ;
snapshot . Records = new CultPersistedRecord [ recordCount ] ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var recordBytes = new byte [ recordCount ] [ ] ;
2026-05-08 02:31:06 +01:00
for ( var index = 0 ; index < recordCount ; index + + )
{
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var start = reader . Position ;
2026-05-08 02:31:06 +01:00
snapshot . Records [ index ] = ReadPersistedRecord ( ref reader ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
recordBytes [ index ] = reader . Sequence . Slice ( start , reader . Position ) . ToArray ( ) ;
2026-05-08 02:31:06 +01:00
}
2026-09-30 04:19:35 +02:00
RequirePublishedSchemas ( snapshot ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
return ( snapshot , catalogBytes , recordBytes ) ;
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
}
catch ( CultStoreUnreadableException )
2026-05-08 02:31:06 +01:00
{
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
throw ;
}
catch ( Exception ex ) when ( ex is not OutOfMemoryException )
{
2026-09-30 06:54:17 +02:00
throw new CultStoreUnreadableException ( ex . Message , innerException : ex ) ;
2026-05-08 02:31:06 +01:00
}
}
2026-09-30 04:19:35 +02:00
// A record whose schema the store's catalog does not publish cannot be read by any runtime that has only the file.
private static void RequirePublishedSchemas ( CultPersistedStoreSnapshot snapshot )
{
var published = new HashSet < string > ( StringComparer . Ordinal ) ;
foreach ( var entry in snapshot . SchemaCatalog )
{
published . Add ( entry . SchemaId ) ;
foreach ( var compatible in entry . CompatibleSchemaIds )
published . Add ( compatible ) ;
}
var unpublished = snapshot . Records . FirstOrDefault ( record = > ! published . Contains ( record . SchemaId ) ) ;
if ( unpublished ! = null )
{
throw new CultStoreUnreadableException (
$"Record '{unpublished.Key}' names schema '{unpublished.SchemaId}', which the store's schema catalog does not publish." ) ;
}
}
2026-09-30 01:59:28 +02:00
private static void RequireOneArray ( byte [ ] bytes )
{
var whole = new MessagePackReader ( bytes ) ;
try
{
if ( whole . NextMessagePackType ! = MessagePackType . Array )
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
throw new CultStoreUnreadableException ( $"The store is a MessagePack {whole.NextMessagePackType}, not an array." ) ;
2026-09-30 01:59:28 +02:00
whole . Skip ( ) ;
}
catch ( Exception ex ) when ( ex is EndOfStreamException or MessagePackSerializationException or InvalidOperationException )
{
2026-09-30 06:54:17 +02:00
throw new CultStoreUnreadableException ( "The store is not one complete MessagePack array." , innerException : ex ) ;
2026-09-30 01:59:28 +02:00
}
if ( ! whole . End )
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
throw new CultStoreUnreadableException ( "The store has bytes after its MessagePack array." ) ;
2026-09-30 01:59:28 +02:00
}
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format or record it found.</summary>
2026-09-29 02:04:33 +02:00
public static void RequireSingleFileFormat ( CultPersistedStoreSnapshot snapshot )
{
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
var version = snapshot . FormatVersion ;
if ( ! string . Equals ( version , CultPersistedStoreSnapshot . FormatV1 , StringComparison . Ordinal ) & &
CultCache: element id rules, atomic minting, minted override values, the v3 marker
The id rule is one plan, made before anything is written: Plan decides every fill and
refuses a duplicate, a random id that is not 12 lowercase hex characters, and a derived
id whose source is null or empty, naming the record and list (CultElementIdException).
A load refuses what a write refuses. Apply and Undo make minting part of the admission:
a refused or uncommitted write leaves the caller's documents as they were.
Override values mint inside Resolve, into the decoded copy, so the public
CultVariantOverride.Set, C1-era overrides and Override() all get ids on write and on load;
Override() no longer mints. Interface unions are judged on their concrete types, the same
rule registration and minting share, and a subtype reusing the id's key is refused at
registration. Dictionary keys format with the invariant culture.
Records minted at load are marked per entry (IdsInMemoryOnly) instead of listed by key, so
a deleted record leaves the mark with it, and a flush of the whole store clears it. A store
whose types reach an object list declares cultcache.store.v3 (directory: v5), which every
reader older than ids refuses; a marked file stays marked when a writer merges onto it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:27:16 +02:00
! string . Equals ( version , CultPersistedStoreSnapshot . FormatV2 , StringComparison . Ordinal ) & &
! string . Equals ( version , CultPersistedStoreSnapshot . FormatV3 , StringComparison . Ordinal ) )
2026-09-29 02:04:33 +02:00
{
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
throw new CultStoreUnreadableException (
Merge CultCache R1 (f789c8d1) into C2a: one reader per runtime, R1's failure taxonomy
R1's rules win where both sides decided the same question: a zero-byte file is
not a store (refused as unreadable in every runtime), only nothing at the path is
empty, a dangling link is an I/O error, refusals never echo stored values and echo
a header only as cultcache.store.v<digits>, writes report Rejected, NotReplaced
and ReplacedNotDurable. C2a's rules win on what it added: the v3 element-id
header, variants, whole-file proof, typed unreadable errors in C#, TypeScript and
Python. Rust keeps R1's CultCacheStoreUnreadable{path, kind}; C2a's
StoreUnreadableError is deleted. The readability manifest's zero-byte row is
refuses x4.
Not yet built or tested: verification follows in the next commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:41:35 +02:00
$"Store format {DescribeHeader(version)} is not readable; this runtime reads {CultPersistedStoreSnapshot.FormatV1}, {CultPersistedStoreSnapshot.FormatV2} and {CultPersistedStoreSnapshot.FormatV3}." ) ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
}
CultCache: element id rules, atomic minting, minted override values, the v3 marker
The id rule is one plan, made before anything is written: Plan decides every fill and
refuses a duplicate, a random id that is not 12 lowercase hex characters, and a derived
id whose source is null or empty, naming the record and list (CultElementIdException).
A load refuses what a write refuses. Apply and Undo make minting part of the admission:
a refused or uncommitted write leaves the caller's documents as they were.
Override values mint inside Resolve, into the decoded copy, so the public
CultVariantOverride.Set, C1-era overrides and Override() all get ids on write and on load;
Override() no longer mints. Interface unions are judged on their concrete types, the same
rule registration and minting share, and a subtype reusing the id's key is refused at
registration. Dictionary keys format with the invariant culture.
Records minted at load are marked per entry (IdsInMemoryOnly) instead of listed by key, so
a deleted record leaves the mark with it, and a flush of the whole store clears it. A store
whose types reach an object list declares cultcache.store.v3 (directory: v5), which every
reader older than ids refuses; a marked file stays marked when a writer merges onto it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:27:16 +02:00
// Only a v2 or v3 store may hold a variant: a v1 header over a variant record is a store no reader could trust.
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
if ( string . Equals ( version , CultPersistedStoreSnapshot . FormatV1 , StringComparison . Ordinal ) )
{
var variant = snapshot . Records . FirstOrDefault ( record = > record . Variant ! = null ) ;
if ( variant ! = null )
{
Store readability: one typed refusal, one full reader, one store shape
CultStoreUnreadableException (a NotSupportedException) is the one refusal for a store file this runtime cannot read, thrown
by the single-file reader, the rewriting paths and CultMesh's reads, with the cause as the inner exception. The reader is exactly
one array of three slots (or an empty array): a header alone and an extra top-level slot are refused instead of read as empty or
skipped. CultMesh's older-catalog fallback is the same reader with the older entry reader, so it also requires one array.
Vectors: bad-body, header-only, missing-schema, variant-slot, extra-top-slot in the readability set, and a legacy-catalog store
with and without bytes after it for CultMesh. The persistence contract says what is true.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 04:06:44 +02:00
throw new CultStoreUnreadableException (
CultCache: element id rules, atomic minting, minted override values, the v3 marker
The id rule is one plan, made before anything is written: Plan decides every fill and
refuses a duplicate, a random id that is not 12 lowercase hex characters, and a derived
id whose source is null or empty, naming the record and list (CultElementIdException).
A load refuses what a write refuses. Apply and Undo make minting part of the admission:
a refused or uncommitted write leaves the caller's documents as they were.
Override values mint inside Resolve, into the decoded copy, so the public
CultVariantOverride.Set, C1-era overrides and Override() all get ids on write and on load;
Override() no longer mints. Interface unions are judged on their concrete types, the same
rule registration and minting share, and a subtype reusing the id's key is refused at
registration. Dictionary keys format with the invariant culture.
Records minted at load are marked per entry (IdsInMemoryOnly) instead of listed by key, so
a deleted record leaves the mark with it, and a flush of the whole store clears it. A store
whose types reach an object list declares cultcache.store.v3 (directory: v5), which every
reader older than ids refuses; a marked file stays marked when a writer merges onto it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 18:27:16 +02:00
$"Record '{variant.Key}' (schema '{variant.SchemaId}') is a variant but the store declares {version}; variants need {CultPersistedStoreSnapshot.FormatV2} or {CultPersistedStoreSnapshot.FormatV3}." ) ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
}
2026-09-29 02:04:33 +02:00
}
}
2026-09-30 18:35:20 +02:00
// A store header as a refusal may show it: echoed in the shape cultcache.store.v<digits>, and otherwise described
// only by its length, since the bytes are the store's.
2026-10-02 20:49:12 +02:00
internal static string DescribeHeader ( string header )
2026-09-30 18:35:20 +02:00
{
const string prefix = "cultcache.store.v" ;
var version = header . StartsWith ( prefix , StringComparison . Ordinal ) ? header . Substring ( prefix . Length ) : "" ;
return version . Length > 0 & & version . All ( character = > character > = '0' & & character < = '9' )
? header
: $"an unrecognised cultcache.store.* header of {System.Text.Encoding.UTF8.GetByteCount(header)} bytes" ;
}
2026-05-08 02:31:06 +01:00
private static void WritePersistedRecord ( ref MessagePackWriter writer , CultPersistedRecord record )
{
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
writer . WriteArrayHeader ( record . Variant = = null ? PersistedRecordFieldCount : VariantRecordFieldCount ) ;
2026-05-08 02:31:06 +01:00
writer . Write ( record . Key ) ;
writer . Write ( record . SchemaId ) ;
writer . Write ( record . StoredAt ) ;
writer . Write ( record . Payload ) ;
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
if ( record . Variant ! = null )
{
// Slot 4: [baseKey, overrides[]]; an override is [op, path[], id, value], a path step [slot, elementId],
// and the value is the member's own MessagePack encoding, inline.
writer . WriteArrayHeader ( 2 ) ;
writer . Write ( record . Variant . BaseKey ) ;
writer . WriteArrayHeader ( record . Variant . Overrides . Count ) ;
foreach ( var entry in record . Variant . Overrides )
{
writer . WriteArrayHeader ( 4 ) ;
writer . Write ( ( int ) entry . Op ) ;
writer . WriteArrayHeader ( entry . Path . Count ) ;
foreach ( var step in entry . Path )
{
writer . WriteArrayHeader ( 2 ) ;
writer . Write ( step . Slot ) ;
writer . Write ( step . ElementId ) ;
}
writer . Write ( entry . Id ) ;
writer . WriteRaw ( entry . Value ) ;
}
}
}
private static CultVariantDelta ReadVariant ( ref MessagePackReader reader , string key , string schemaId )
{
var slots = reader . ReadArrayHeader ( ) ;
if ( slots ! = 2 )
{
throw new NotSupportedException (
$"Record '{key}' (schema '{schemaId}') has a variant slot of {slots} entries; this runtime reads 2 (base key, overrides)." ) ;
}
var baseKey = reader . ReadString ( ) ? ? string . Empty ;
var count = reader . ReadArrayHeader ( ) ;
var overrides = new CultVariantOverride [ count ] ;
for ( var index = 0 ; index < count ; index + + )
{
var fields = reader . ReadArrayHeader ( ) ;
if ( fields ! = 4 )
{
throw new NotSupportedException (
$"Record '{key}' (schema '{schemaId}') has an override of {fields} entries; this runtime reads 4 (op, path, id, value)." ) ;
}
var op = reader . ReadInt32 ( ) ;
if ( op is < 0 or > 2 )
{
throw new NotSupportedException ( $"Record '{key}' (schema '{schemaId}') has an override with unknown op {op}." ) ;
}
var stepCount = reader . ReadArrayHeader ( ) ;
var path = new CultPathStep [ stepCount ] ;
for ( var step = 0 ; step < stepCount ; step + + )
{
var stepFields = reader . ReadArrayHeader ( ) ;
if ( stepFields ! = 2 )
{
throw new NotSupportedException (
$"Record '{key}' (schema '{schemaId}') has a path step of {stepFields} entries; this runtime reads 2 (slot, elementId)." ) ;
}
path [ step ] = new CultPathStep ( reader . ReadInt32 ( ) , reader . ReadString ( ) ? ? string . Empty ) ;
}
var id = reader . ReadString ( ) ? ? string . Empty ;
var start = reader . Position ;
reader . Skip ( ) ;
var value = reader . Sequence . Slice ( start , reader . Position ) . ToArray ( ) ;
overrides [ index ] = new CultVariantOverride ( ( CultOverrideOp ) op , path , id , value ) ;
}
return new CultVariantDelta ( baseKey , overrides ) ;
2026-05-08 02:31:06 +01:00
}
2026-09-29 02:04:33 +02:00
public static CultPersistedRecord ReadPersistedRecord ( ref MessagePackReader reader )
2026-05-08 02:31:06 +01:00
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var record = new CultPersistedRecord ( ) ;
if ( fieldCount > 0 )
{
record . Key = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 1 )
{
record . SchemaId = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
record . StoredAt = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
record . Payload = reader . ReadBytes ( ) ? . ToArray ( ) ? ? Array . Empty < byte > ( ) ;
}
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
if ( fieldCount > VariantRecordFieldCount )
2026-05-08 02:31:06 +01:00
{
2026-09-29 02:04:33 +02:00
throw new NotSupportedException (
C# core: variant records, top-level Set overrides, resolution in the admitting hold
A variant record stores [baseKey, overrides[]] in record slot 4 and an empty
payload; the cache resolves it against its base inside the hold that admits it or
any record on its base chain, and re-resolves dependents in the same Apply, so a
base change reaches its variants with contiguous sequences and no stamp. Plain
Upsert at a variant key is refused (Flatten is the explicit way); cycle, missing
base, different concrete type, evicted base with survivors and a name that
shadows the base are refused naming the keys. Overrides of a dropped slot or an
undecodable value are ignored with a warning and reported as compatible drift.
The store writes cultcache.store.v2 only when it holds a variant and reads v1 and
v2. The codec pair CultInspectorModel took becomes CultCodec, shared with the
cache, which needs it to clone documents and overlay override values. The
directory store, CultMesh single-file reads and CultNet row building refuse a
variant by name until their own cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:51:53 +02:00
$"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount} " +
$"(or {VariantRecordFieldCount} for a variant)." ) ;
}
if ( fieldCount = = VariantRecordFieldCount )
{
record . Variant = ReadVariant ( ref reader , record . Key , record . SchemaId ) ;
if ( record . Payload . Length ! = 0 )
{
throw new NotSupportedException (
$"Record '{record.Key}' (schema '{record.SchemaId}') is a variant and must carry an empty payload." ) ;
}
2026-05-08 02:31:06 +01:00
}
return record ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
internal static byte [ ] SerializeCatalogEntry ( CultSchemaCatalogEntry entry )
2026-10-03 03:05:08 +02:00
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
WriteSchemaCatalogEntry ( ref writer , entry ) ;
2026-10-03 03:05:08 +02:00
writer . Flush ( ) ;
return buffer . WrittenSpan . ToArray ( ) ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
private static void WriteSchemaCatalogEntry ( ref MessagePackWriter writer , CultSchemaCatalogEntry entry )
2026-05-08 02:31:06 +01:00
{
writer . WriteArrayHeader ( SchemaCatalogEntryFieldCount ) ;
writer . Write ( entry . SchemaId ) ;
writer . Write ( entry . SchemaName ) ;
writer . Write ( entry . SchemaVersion ) ;
writer . Write ( entry . ContentHash ) ;
writer . Write ( entry . CanonicalSchemaJson ) ;
writer . WriteArrayHeader ( entry . CompatibleSchemaIds . Length ) ;
foreach ( var schemaId in entry . CompatibleSchemaIds )
{
writer . Write ( schemaId ) ;
}
2026-05-09 11:45:46 +01:00
writer . WriteArrayHeader ( entry . Members . Length ) ;
foreach ( var member in entry . Members )
{
WriteSchemaCatalogMember ( ref writer , member ) ;
}
2026-05-08 02:31:06 +01:00
}
private static CultSchemaCatalogEntry ReadSchemaCatalogEntry ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var entry = new CultSchemaCatalogEntry ( ) ;
if ( fieldCount > 0 )
{
entry . SchemaId = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 1 )
{
entry . SchemaName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
entry . SchemaVersion = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
entry . ContentHash = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 4 )
{
entry . CanonicalSchemaJson = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 5 )
{
var compatibleCount = reader . ReadArrayHeader ( ) ;
entry . CompatibleSchemaIds = new string [ compatibleCount ] ;
for ( var index = 0 ; index < compatibleCount ; index + + )
{
entry . CompatibleSchemaIds [ index ] = reader . ReadString ( ) ? ? string . Empty ;
}
}
2026-05-09 11:45:46 +01:00
if ( fieldCount > 6 )
{
var memberCount = reader . ReadArrayHeader ( ) ;
entry . Members = new CultSchemaMemberCatalogEntry [ memberCount ] ;
for ( var index = 0 ; index < memberCount ; index + + )
{
entry . Members [ index ] = ReadSchemaCatalogMember ( ref reader ) ;
}
}
2026-05-08 02:31:06 +01:00
for ( var index = SchemaCatalogEntryFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return entry ;
}
2026-05-09 11:45:46 +01:00
private static void WriteSchemaCatalogMember ( ref MessagePackWriter writer , CultSchemaMemberCatalogEntry member )
{
writer . WriteArrayHeader ( SchemaCatalogMemberFieldCount ) ;
writer . Write ( member . Slot ) ;
writer . Write ( member . MemberName ) ;
writer . Write ( member . TypeName ) ;
writer . Write ( member . IsReference ) ;
writer . Write ( member . IsMany ) ;
writer . Write ( member . TargetSchemaName ) ;
writer . Write ( member . IsName ) ;
writer . Write ( member . IndexAlias ) ;
}
private static CultSchemaMemberCatalogEntry ReadSchemaCatalogMember ( ref MessagePackReader reader )
{
var fieldCount = reader . ReadArrayHeader ( ) ;
var member = new CultSchemaMemberCatalogEntry ( ) ;
if ( fieldCount > 0 )
{
member . Slot = reader . ReadInt32 ( ) ;
}
if ( fieldCount > 1 )
{
member . MemberName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 2 )
{
member . TypeName = reader . ReadString ( ) ? ? string . Empty ;
}
if ( fieldCount > 3 )
{
member . IsReference = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 4 )
{
member . IsMany = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 5 )
{
member . TargetSchemaName = reader . ReadString ( ) ;
}
if ( fieldCount > 6 )
{
member . IsName = reader . ReadBoolean ( ) ;
}
if ( fieldCount > 7 )
{
member . IndexAlias = reader . ReadString ( ) ;
}
for ( var index = SchemaCatalogMemberFieldCount ; index < fieldCount ; index + + )
{
reader . Skip ( ) ;
}
return member ;
}
2026-05-08 01:13:15 +01:00
}
public class SingleFileMessagePackBackingStore : SingleFileBackingStore
{
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
public SingleFileMessagePackBackingStore ( string filePath , bool readOnly = false ) : base ( filePath , readOnly )
2026-05-08 01:13:15 +01:00
{
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
protected override byte [ ] SerializeStore ( string formatVersion , IReadOnlyList < byte [ ] > catalogEntries , IReadOnlyList < byte [ ] > records )
2026-05-08 01:13:15 +01:00
{
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
return CultDocumentMessagePackSerialization . SerializeStore ( formatVersion , catalogEntries , records ) ;
2026-05-08 01:13:15 +01:00
}
CultCache: a record's version is the SHA-256 of its stored bytes; conditions, pulls, the staleness check and a refusal's reload compare it, never storedAt
Adds CultStoredDocument.StoredVersion, CacheBackingStore.VersionOf and the
SingleFileBackingStore.SerializeRecord hook, and swaps every identity comparison
in the same commit: SameStoredIdentity stays the one comparison, over versions.
Deletes the Identity helper, the (schemaId, storedAt) Expected tuple, the
in-memory fake-record projection and the directory's storedAt arrival.
Breaking public API for 2.0.0: CultCommitRequest.Expected, ConditionsHold and
the abstract SingleFileBackingStore.SerializeRecord.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:23:31 +02:00
protected override byte [ ] SerializeRecord ( CultPersistedRecord record )
{
return CultDocumentMessagePackSerialization . SerializePersistedRecord ( record ) ;
}
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
protected override byte [ ] SerializeCatalogEntry ( CultSchemaCatalogEntry entry )
{
return CultDocumentMessagePackSerialization . SerializeCatalogEntry ( entry ) ;
}
protected override ( CultPersistedStoreSnapshot Snapshot , byte [ ] [ ] CatalogBytes , byte [ ] [ ] RecordBytes ) ReadStore ( byte [ ] data )
2026-05-08 01:13:15 +01:00
{
CultCache: the store owns a file's bytes, not the record object
Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every
drop-on-assign hook and the catalog's content guard. A decoded record or snapshot
carries no stored bytes, so an in-place edit of its payload or overrides can never
be shadowed by cached bytes, and the public snapshot and record serializers encode
every record from its fields.
SingleFileBackingStore now reads through ReadStore, which returns the snapshot and,
parallel to it, the exact slice the file holds for each catalog entry and record. Its
durable index keeps, by record key, that slice and its SHA-256 (hashed when a
comparison asks), which is the record's version. A write re-encodes every record
the writer staged and copies every other record, and every catalog entry it keeps,
as the slice the file holds. The directory store keeps its manifest's catalog slices
the same way. SerializeSnapshot/DeserializeSnapshot hooks become
SerializeStore/ReadStore plus SerializeCatalogEntry.
A staged but unchanged non-canonical record is now re-encoded canonically and gets
a new version; an untouched one keeps its bytes and version.
Tests: in-place edits through the snapshot API (payload, override) and through a
held document (collection, nested object) are written as edited; an untouched
non-canonical record survives a neighbour's write byte for byte; a staged one is
re-encoded. The cost test counts record hashes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var store = CultDocumentMessagePackSerialization . ReadStore ( data ) ;
CultDocumentMessagePackSerialization . RequireSingleFileFormat ( store . Snapshot ) ;
return store ;
2026-05-08 01:13:15 +01:00
}
protected override byte [ ] SerializePayload ( object document )
{
2026-07-20 01:48:56 +01:00
return CultDocumentMessagePackSerialization . SerializeUntyped ( document , document . GetType ( ) , Registry ) ;
2026-05-08 01:13:15 +01:00
}
protected override object DeserializePayload ( Type documentType , byte [ ] payload )
{
2026-07-20 01:48:56 +01:00
return CultDocumentMessagePackSerialization . DeserializeUntyped ( documentType , payload , Registry ) ;
2026-05-08 01:13:15 +01:00
}
}