diff --git a/src/GameCult.Networking/Messages.cs b/src/GameCult.Networking/Messages.cs
index 331dfa43..aa14a732 100644
--- a/src/GameCult.Networking/Messages.cs
+++ b/src/GameCult.Networking/Messages.cs
@@ -15,6 +15,8 @@ namespace GameCult.Networking
// Sample application payload tags preserved for cross-runtime compatibility.
Union(5, typeof(ChangeNameMessage)),
Union(6, typeof(ChatMessage)),
+ Union(7, typeof(SchemaCatalogRequestMessage)),
+ Union(8, typeof(SchemaCatalogResponseMessage)),
MessagePackObject]
public abstract class Message
{
@@ -110,4 +112,95 @@ namespace GameCult.Networking
[Key(0)] public string Error = string.Empty;
}
+ ///
+ /// Requests a catalog of schemas that the remote runtime can safely exchange.
+ ///
+ [MessagePackObject]
+ public class SchemaCatalogRequestMessage : Message
+ {
+ ///
+ /// Correlation identifier for the discovery request.
+ ///
+ [Key(0)] public string MessageId = string.Empty;
+
+ ///
+ /// Whether the remote runtime should include inline JSON schema bodies.
+ ///
+ [Key(1)] public bool IncludeSchemaJson;
+
+ ///
+ /// Optional schema-id filter.
+ ///
+ [Key(2)] public string[]? SchemaIds;
+
+ ///
+ /// Optional schema-kind filter.
+ ///
+ [Key(3)] public string[]? Kinds;
+ }
+
+ ///
+ /// Describes one schema that the runtime knows how to exchange safely.
+ ///
+ [MessagePackObject]
+ public class SchemaDescriptorMessage
+ {
+ ///
+ /// Stable canonical schema identifier.
+ ///
+ [Key(0)] public string SchemaId = string.Empty;
+
+ ///
+ /// Discovery kind such as wire_message, document_payload, or shared_contract.
+ ///
+ [Key(1)] public string Kind = string.Empty;
+
+ ///
+ /// Optional runtime message schema version.
+ ///
+ [Key(2)] public string? SchemaVersion;
+
+ ///
+ /// Optional CultCache/CultNet document type name.
+ ///
+ [Key(3)] public string? DocumentType;
+
+ ///
+ /// Human-facing schema title.
+ ///
+ [Key(4)] public string? Title;
+
+ ///
+ /// Wire contracts that can transport this schema safely.
+ ///
+ [Key(5)] public string[] WireContracts = Array.Empty();
+
+ ///
+ /// SHA-256 hash of the canonical schema JSON.
+ ///
+ [Key(6)] public string ContentHash = string.Empty;
+
+ ///
+ /// Optional canonical JSON Schema body as a string.
+ ///
+ [Key(7)] public string? SchemaJson;
+ }
+
+ ///
+ /// Returns the remote runtime's schema catalog for safe exchange planning.
+ ///
+ [MessagePackObject]
+ public class SchemaCatalogResponseMessage : Message
+ {
+ ///
+ /// Correlation identifier matching the request.
+ ///
+ [Key(0)] public string MessageId = string.Empty;
+
+ ///
+ /// Discovered schemas available for safe exchange.
+ ///
+ [Key(1)] public SchemaDescriptorMessage[] Schemas = Array.Empty();
+ }
+
}
diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md
index 3aa7ad3b..30c3a31d 100644
--- a/src/GameCult.Networking/README.md
+++ b/src/GameCult.Networking/README.md
@@ -26,6 +26,8 @@ Keep the distinction clean:
- auth/session semantics belong to the core library
- application payload contracts should be explicit, versioned, and kept in sync
across runtimes
+- runtimes should be able to ask each other which schemas they speak before
+ pretending a shared pipe implies shared understanding
- if multiple apps share a message contract, they should be able to talk
directly without bespoke translation sludge
@@ -41,6 +43,8 @@ Sample payloads:
- `ChangeNameMessage`
- `ChatMessage`
+- `SchemaCatalogRequestMessage`
+- `SchemaCatalogResponseMessage`
Those now live under `Samples/` to make it obvious they are example application
messages, not the entire meaning of the library.
diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs
index 06a17280..887052dc 100644
--- a/tests/GameCult.Networking.Tests/NetworkingTests.cs
+++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs
@@ -107,6 +107,45 @@ namespace GameCult.Networking.Tests
Assert.That(roundTrip.Nonce, Is.EqualTo(message.Nonce));
}
+ [Test]
+ public void MessageSerialization_RoundTrips_SchemaCatalogMessages()
+ {
+ var client = new Client(DevelopmentClientSecurity);
+ var message = new SchemaCatalogResponseMessage
+ {
+ MessageId = "catalog-1",
+ Schemas =
+ [
+ new SchemaDescriptorMessage
+ {
+ SchemaId = "https://example.test/contracts/example.schema.json",
+ Kind = "shared_contract",
+ SchemaVersion = "example.contract.v0",
+ Title = "Example Contract",
+ WireContracts = ["cultnet.schema.v0", "gamecult.networking.v0"],
+ ContentHash = "deadbeef",
+ SchemaJson = "{\"type\":\"object\"}"
+ }
+ ]
+ };
+
+ var serializationType = client.GetType().Assembly
+ .GetType("GameCult.Networking.MessageSerialization", throwOnError: true)!;
+ var serialize = serializationType.GetMethod("Serialize", BindingFlags.Public | BindingFlags.Static)!
+ .MakeGenericMethod(typeof(Message));
+ var deserialize = serializationType.GetMethod("Deserialize", BindingFlags.Public | BindingFlags.Static)!
+ .MakeGenericMethod(typeof(Message));
+
+ var payload = (byte[])serialize.Invoke(null, [message])!;
+ var roundTrip = (SchemaCatalogResponseMessage)deserialize.Invoke(null, [payload])!;
+
+ Assert.That(roundTrip.MessageId, Is.EqualTo("catalog-1"));
+ Assert.That(roundTrip.Schemas, Has.Length.EqualTo(1));
+ Assert.That(roundTrip.Schemas[0].SchemaId, Is.EqualTo("https://example.test/contracts/example.schema.json"));
+ Assert.That(roundTrip.Schemas[0].WireContracts, Is.EqualTo(["cultnet.schema.v0", "gamecult.networking.v0"]));
+ Assert.That(roundTrip.Schemas[0].SchemaJson, Is.EqualTo("{\"type\":\"object\"}"));
+ }
+
[Test]
public void MessageSerialization_Rejects_InvalidPayload()
{