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-09-13 21:12:53 +02:00
public static readonly MessagePackSerializerOptions Options = Compose ( Array . Empty < IFormatterResolver > ( ) ) ;
private static readonly ConcurrentDictionary < Assembly , MessagePackSerializerOptions > AssemblyOptions = new ( ) ;
public static MessagePackSerializerOptions OptionsFor ( Assembly documentAssembly )
{
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 ( ) ;
return resolvers . Length = = 0 ? Options : Compose ( resolvers ) ;
} ) ;
}
private static MessagePackSerializerOptions Compose ( IFormatterResolver [ ] consumerResolvers )
{
return MessagePackSerializerOptions . Standard
2026-05-08 01:13:15 +01:00
. WithResolver ( CompositeResolver . Create (
Add the canonical MessagePack encoding for every public CultMath value type
CultMathResolver formats float2/3/4, double2/3, int2/3/4, bool2/3/4,
quaternion, Color32, Random, float2x2, float3x3, rect and CultCellular as
component arrays, and sits in the default CultCache resolver chain so no
consumer registers anything. It lives in GameCult.Caching.MessagePack, which
already owns that chain; CultMath core stays dependency-free.
Vector, rect and Aetheria-formatter byte shapes are pinned by golden tests, a
reflection test fails when a public CultMath value type has no formatter, and
decode skips extra elements and refuses nil.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:13:22 +02:00
consumerResolvers . Append ( CultDocumentResolver . Instance ) . Append ( CultMathResolver . Instance ) . Append ( StandardResolver . Instance ) . ToArray ( ) ) )
2026-09-13 21:12:53 +02:00
. WithSecurity ( CultMessagePackSecurity . Instance ) ;
}
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-09-13 21:12:53 +02:00
return MessagePackSerializer . Serialize ( type , value , OptionsFor ( type . Assembly ) ) ;
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-09-13 21:12:53 +02:00
return MessagePackSerializer . Deserialize ( type , payload , OptionsFor ( type . Assembly ) )
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 ;
}
var buffer = new ArrayBufferWriter < byte > ( ) ;
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 ) ;
}
public static byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot )
{
var buffer = new global :: System . Buffers . ArrayBufferWriter < byte > ( ) ;
var writer = new MessagePackWriter ( buffer ) ;
writer . WriteArrayHeader ( StoreSnapshotFieldCount ) ;
writer . Write ( snapshot . FormatVersion ) ;
writer . WriteArrayHeader ( snapshot . SchemaCatalog . Length ) ;
foreach ( var entry in snapshot . SchemaCatalog )
{
WriteSchemaCatalogEntry ( ref writer , entry ) ;
}
writer . WriteArrayHeader ( snapshot . Records . Length ) ;
foreach ( var record in snapshot . Records )
{
WritePersistedRecord ( ref writer , record ) ;
}
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>
2026-10-03 01:59:13 +02:00
public static CultPersistedStoreSnapshot DeserializeSnapshot ( 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 )
return snapshot ;
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 ] ;
for ( var index = 0 ; index < catalogCount ; index + + )
{
2026-10-03 01:59:13 +02:00
snapshot . SchemaCatalog [ index ] = ReadSchemaCatalogEntry ( ref reader ) ;
2026-05-08 02:31:06 +01:00
}
var recordCount = reader . ReadArrayHeader ( ) ;
snapshot . Records = new CultPersistedRecord [ recordCount ] ;
for ( var index = 0 ; index < recordCount ; index + + )
{
snapshot . Records [ index ] = ReadPersistedRecord ( ref reader ) ;
}
2026-09-30 04:19:35 +02:00
RequirePublishedSchemas ( snapshot ) ;
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
return snapshot ;
}
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 ;
}
private static void WriteSchemaCatalogEntry ( ref MessagePackWriter writer , CultSchemaCatalogEntry entry )
{
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
{
}
protected override byte [ ] SerializeSnapshot ( CultPersistedStoreSnapshot snapshot )
{
2026-05-08 02:31:06 +01:00
return CultDocumentMessagePackSerialization . SerializeSnapshot ( snapshot ) ;
2026-05-08 01:13:15 +01:00
}
protected override CultPersistedStoreSnapshot DeserializeSnapshot ( byte [ ] data )
{
2026-09-29 02:04:33 +02:00
var snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( data ) ;
CultDocumentMessagePackSerialization . RequireSingleFileFormat ( snapshot ) ;
return snapshot ;
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
}
}