diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs
index eb23d503..805312a3 100644
--- a/src/GameCult.Networking/CultNetDocumentRegistry.cs
+++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs
@@ -165,10 +165,31 @@ namespace GameCult.Networking
}
///
- /// Registers a document binding.
+ /// Registers a document binding. One binding owns a document type and a schema id: a binding claiming a type or a schema
+ /// id another binding already holds is refused with , naming the new binding's
+ /// schema id and both document types, and nothing is bound. Binding a type to the schema id it already holds replaces
+ /// that binding.
///
public CultNetDocumentRegistry Register(CultNetDocumentBinding binding)
{
+ var holder =
+ _bindingsByType.TryGetValue(binding.DocumentType, out var sameType) &&
+ !string.Equals(sameType.SchemaId, binding.SchemaId, StringComparison.Ordinal)
+ ? sameType
+ : _bindingsBySchemaId.TryGetValue(binding.SchemaId, out var sameSchema) &&
+ sameSchema.DocumentType != binding.DocumentType
+ ? sameSchema
+ : null;
+ if (holder != null)
+ {
+ throw new CultSchemaConflictException(
+ $"CultNet schema id \"{holder.SchemaId}\" is already bound to type \"{holder.DocumentType.FullName}\"; " +
+ $"type \"{binding.DocumentType.FullName}\" (schema id \"{binding.SchemaId}\") cannot also be bound.",
+ binding.SchemaId,
+ new[] { holder.DocumentType.FullName ?? holder.DocumentType.Name, binding.DocumentType.FullName ?? binding.DocumentType.Name },
+ string.Empty);
+ }
+
_bindingsBySchemaId[binding.SchemaId] = binding;
_bindingsByType[binding.DocumentType] = binding;
return this;
diff --git a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs
new file mode 100644
index 00000000..6f48ee7e
--- /dev/null
+++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryConflictTests.cs
@@ -0,0 +1,91 @@
+#nullable enable
+using System;
+using GameCult.Caching;
+using MessagePack;
+using NUnit.Framework;
+
+namespace GameCult.Networking.Tests
+{
+ // F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea, I support retiring it." One binding owns a
+ // document type and a schema id, in the reference runtime as in Rust, Kotlin and TypeScript.
+ public sealed class CultNetDocumentRegistryConflictTests
+ {
+ private static CultNetDocumentBinding Bind(string? schemaId = null) where T : class =>
+ CultNetDocumentBinding.ForDocument(CultDocumentRegistry.Shared, schemaId);
+
+ // Two bindings stand ahead of the holder, so a conflict check that looks only at the first holder misses it.
+ private static CultNetDocumentRegistry RegistryHolding(CultNetDocumentBinding holder) =>
+ new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
+ .Register(Bind())
+ .Register(Bind())
+ .Register(holder);
+
+ [Test]
+ public void A_second_type_for_one_schema_id_is_refused_typed_in_either_order()
+ {
+ var first = Bind("f5.shared");
+ var second = Bind("f5.shared");
+ foreach (var (held, claimant) in new[] { (first, second), (second, first) })
+ {
+ var registry = RegistryHolding(held);
+ var error = Assert.Throws(() => registry.Register(claimant))!;
+ Assert.That(error.SchemaId, Is.EqualTo("f5.shared"));
+ Assert.That(error.SchemaNames, Is.EqualTo(new[] { held.DocumentType.FullName, claimant.DocumentType.FullName }));
+ Assert.That(error.RecordKey, Is.EqualTo(string.Empty));
+ Assert.That(error.Message, Does.Contain($"type \"{claimant.DocumentType.FullName}\""));
+ // Nothing was bound: the schema id still resolves to the first type, and the claimant's type to nothing.
+ Assert.That(registry.GetBySchemaId("f5.shared"), Is.SameAs(held));
+ Assert.That(registry.GetByDocumentType(claimant.DocumentType), Is.Null);
+ }
+ }
+
+ [Test]
+ public void A_type_bound_to_one_schema_id_cannot_be_bound_to_another()
+ {
+ var own = Bind();
+ var registry = RegistryHolding(own);
+ var error = Assert.Throws(() => registry.Register(Bind("f5.elsewhere")))!;
+ Assert.That(error.SchemaId, Is.EqualTo("f5.elsewhere"));
+ Assert.That(registry.GetBySchemaId("f5.elsewhere"), Is.Null);
+ Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(own));
+ }
+
+ [Test]
+ public void Binding_a_type_to_the_schema_id_it_holds_again_replaces_the_binding()
+ {
+ var registry = RegistryHolding(Bind());
+ var again = Bind();
+ registry.Register(again);
+ Assert.That(registry.GetByDocumentType(typeof(ConflictNote)), Is.SameAs(again));
+ Assert.That(registry.GetBySchemaId(again.SchemaId), Is.SameAs(again));
+ }
+
+ [CultDocument("f5.conflict-note", "f5.conflict-note.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ public sealed class ConflictNote
+ {
+ [Key(0)] public string Body = string.Empty;
+ }
+
+ [CultDocument("f5.conflict-other-note", "f5.conflict-other-note.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ public sealed class ConflictOtherNote
+ {
+ [Key(0)] public string Body = string.Empty;
+ }
+
+ [CultDocument("f5.conflict-filler-a", "f5.conflict-filler-a.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ public sealed class ConflictFillerA
+ {
+ [Key(0)] public string Body = string.Empty;
+ }
+
+ [CultDocument("f5.conflict-filler-b", "f5.conflict-filler-b.v1")]
+ [MessagePackObject(AllowPrivate = true)]
+ public sealed class ConflictFillerB
+ {
+ [Key(0)] public string Body = string.Empty;
+ }
+ }
+}