From 452f928bb72534181f9d037d2b8bfe7addfeb2a6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 21:01:07 +0200 Subject: [PATCH] Write an unset CultRecordRef as nil and read it back unset The formatter wrote default(CultRecordRef) as nil but read nil back as "", so re-upserting a loaded record changed its bytes (0xC0 became 0xA0). One in-memory unset value: CultRecordKey.Value is never null, so default(CultRecordRef) equals a ref built from "". One wire form: an empty key writes nil, and nil or a legacy "" reads back as default. Load then save is byte-stable; a store holding "" converges to nil on its next save. Rust, TypeScript and Python have no reference type: a reference member is a string in the consumer's own payload, so there is no sibling codec to align. The store-composition contract now names nil as the one unset wire form. The new test loads a record with an unset ref through a fresh cache, re-upserts and flushes, and requires identical payload bytes; it fails on the old formatter. Co-Authored-By: Claude Opus 5 --- .../CultRecordRefFormatter.cs | 9 ++-- .../Contracts/cultcache-store-composition.md | 11 ++++ src/GameCult.Caching/CultDocumentContracts.cs | 7 ++- src/GameCult.Caching/CultInspectorModel.cs | 4 +- .../SerializationOptionsTests.cs | 51 +++++++++++++++++++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultRecordRefFormatter.cs b/src/GameCult.Caching.MessagePack/CultRecordRefFormatter.cs index a5f984fc..1aa0968f 100644 --- a/src/GameCult.Caching.MessagePack/CultRecordRefFormatter.cs +++ b/src/GameCult.Caching.MessagePack/CultRecordRefFormatter.cs @@ -3,16 +3,19 @@ using MessagePack.Formatters; namespace GameCult.Caching; +// An unset reference (the empty key) is nil on the wire and default(CultRecordRef) in memory; a "" read from older +// stores is the same unset reference, so load then save is byte-stable. public sealed class CultRecordRefFormatter : IMessagePackFormatter> { public void Serialize(ref MessagePackWriter writer, CultRecordRef value, MessagePackSerializerOptions options) { - options.Resolver.GetFormatterWithVerify().Serialize(ref writer, value.Key.Value, options); + if (value.Key.Value.Length == 0) writer.WriteNil(); + else writer.Write(value.Key.Value); } public CultRecordRef Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { - var key = options.Resolver.GetFormatterWithVerify().Deserialize(ref reader, options) ?? string.Empty; - return new CultRecordRef(new CultRecordKey(key)); + var key = reader.TryReadNil() ? null : reader.ReadString(); + return string.IsNullOrEmpty(key) ? default : new CultRecordRef(new CultRecordKey(key!)); } } diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 8c4fc529..b24c83b3 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -236,6 +236,17 @@ another runtime need not obey the `storedAt` rule. - Deserialization runs under untrusted-data security. `CultRecordRef` is a permitted dictionary key, compared by its key string. +## Record references + +A record reference is its target's key string. An unset reference, the empty +key, is **nil** on the wire, never `""`. A reader takes nil and `""` as the same +unset reference and writes it back as nil, so loading and re-saving an unchanged +record is byte-stable. In C# the unset reference is `default(CultRecordRef)`, +equal to one built from `""`; `CultRecordKey.Value` is never null. Rust, +TypeScript and Python have no reference type: a reference member is a string in +the consumer's own payload value, so a consumer writes an unset one as its +language's null (`null`, `None`, `Option::None`), not as an empty string. + ## Value-type encoding Vector-like value types (Unity.Mathematics, consumer structs) encode as diff --git a/src/GameCult.Caching/CultDocumentContracts.cs b/src/GameCult.Caching/CultDocumentContracts.cs index 0d4f1643..442f5c4f 100644 --- a/src/GameCult.Caching/CultDocumentContracts.cs +++ b/src/GameCult.Caching/CultDocumentContracts.cs @@ -57,12 +57,15 @@ namespace GameCult.Caching public readonly struct CultRecordKey : IEquatable { + private readonly string? _value; + public CultRecordKey(string value) { - Value = value ?? string.Empty; + _value = value; } - public string Value { get; } + // Never null: default(CultRecordKey) and new CultRecordKey("") are one empty key. + public string Value => _value ?? string.Empty; public override string ToString() => Value; diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index f40d9055..c457031f 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -305,7 +305,7 @@ namespace GameCult.Caching return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name!; } - // The key a CultRecordRef holds; null and "" are both the empty key. + // The key a CultRecordRef holds; "" when unset. public static string RecordKey(object? recordRef) => (recordRef as ICultRecordRef)?.Key.Value ?? string.Empty; public CultInspectorShape ShapeOf(Type type) @@ -464,7 +464,7 @@ namespace GameCult.Caching } } - // A key is taken when another serializes the same (a CultRecordRef by its key string, null and "" alike) or when + // A key is taken when another serializes the same (a CultRecordRef by its key string) or when // the rebuilt dictionary type's default comparer calls them equal (0.0 and -0.0 serialize apart but are one double key). private bool Taken(Type documentType, CultInspectorShape dictionary, object key, IEnumerable keys) { diff --git a/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs b/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs index 7127f574..ff17d598 100644 --- a/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs +++ b/tests/GameCult.Caching.Tests/SerializationOptionsTests.cs @@ -38,6 +38,48 @@ namespace GameCult.Caching.Tests Assert.That(Convert.ToHexString(payload), Is.EqualTo(InteropNotePayloadHex)); } + [Test] + public async Task UnsetRefReloadsAndResavesByteIdentical() + { + var path = Path.Combine(Path.GetTempPath(), $"cultlib-unsetref-{Guid.NewGuid():N}.cc"); + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(UnsetRefHolder) }); + byte[] Payload() => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records.Single().Payload; + try + { + using (var seed = new CultCache(registry)) + { + seed.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + await seed.UpsertAsync(typeof(UnsetRefHolder), new UnsetRefHolder { Name = "holder", Set = new CultRecordRef(new CultRecordKey("other")) }, + new CultRecordKey("holder")); + await seed.FlushAsync(); + } + + var written = Payload(); + CultRecordRef unset; + using (var reopened = new CultCache(registry)) + { + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + await reopened.PullAllBackingStoresAsync(); + var loaded = reopened.Get(new CultRecordKey("holder"))!; + unset = loaded.Unset; + await reopened.UpsertAsync(typeof(UnsetRefHolder), loaded, new CultRecordKey("holder")); + await reopened.FlushAsync(); + } + + Assert.That(Payload(), Is.EqualTo(written), "an unset reference loads and re-saves byte-identical"); + Assert.That(unset, Is.EqualTo(default(CultRecordRef))); + Assert.That(unset.Key, Is.EqualTo(new CultRecordKey("")), "default and \"\" are one empty key"); + var legacy = MessagePackSerializer.Serialize("", CultDocumentMessagePackSerialization.Options); + var read = MessagePackSerializer.Deserialize>(legacy, CultDocumentMessagePackSerialization.Options); + Assert.That(MessagePackSerializer.Serialize(read, CultDocumentMessagePackSerialization.Options), Is.EqualTo(new[] { MessagePackCode.Nil }), + "a \"\" reference reads as unset and writes nil"); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + [Test] public async Task RefKeyedDictionaryRoundTrips() { @@ -171,5 +213,14 @@ namespace GameCult.Caching.Tests [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public Dictionary, float> Weights = new(); } + + [CultDocument("tests.unset_ref_holder", "tests.unset_ref_holder.v1")] + [MessagePackObject] + public sealed class UnsetRefHolder + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public CultRecordRef Unset; + [Key(2)] public CultRecordRef Set; + } } }