diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs
new file mode 100644
index 0000000..507b163
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+
+#nullable enable
+
+namespace GameCult.Eve.Surface
+{
+ /// Canonical digest for one immutable Eve command invocation.
+ public static class EveCommandInvocationHash
+ {
+ public static string Compute(EveSurfaceCommandRequest request)
+ {
+ if (request == null) throw new ArgumentNullException(nameof(request));
+ var canonical = new StringBuilder();
+ Append(request.Schema);
+ Append(request.ProviderId);
+ Append(request.SurfaceId);
+ Append(request.OperationRecord.OperationId);
+ Append(request.OperationRecord.SchemaId);
+ Append(request.OperationRecord.RouteKind);
+ Append(request.OperationRecord.RouteDescription);
+ Append(request.OperationRecord.IdempotencyKey);
+ foreach (var field in request.PayloadFields.OrderBy(pair => pair.Key, StringComparer.Ordinal))
+ {
+ Append(field.Key);
+ Append(field.Value);
+ }
+ Append(request.IssuedAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture));
+ Append(request.ClientId);
+ Append(request.CommandBoundary);
+ Append(request.ReceiptSchema);
+ using var sha256 = SHA256.Create();
+ var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(canonical.ToString()));
+ var digest = new StringBuilder(bytes.Length * 2);
+ foreach (var value in bytes) digest.Append(value.ToString("x2", CultureInfo.InvariantCulture));
+ return digest.ToString();
+
+ void Append(string? value)
+ {
+ value ??= "";
+ canonical.Append(value.Length.ToString(CultureInfo.InvariantCulture));
+ canonical.Append(':');
+ canonical.Append(value);
+ }
+ }
+ }
+}
diff --git a/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta
new file mode 100644
index 0000000..cf3cc3e
--- /dev/null
+++ b/packages/org.gamecult.eve.surface/Runtime/EveCommandInvocationHash.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7d5f81f817c44d1ab25d76166354364d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs
index 30156c3..52d71cc 100644
--- a/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs
+++ b/tests/GameCult.Eve.Surface.Serialization.Tests/EveSurfaceSerializationCompatibilityTests.cs
@@ -68,6 +68,46 @@ public sealed class EveSurfaceSerializationCompatibilityTests
private static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard;
+ [Test]
+ public void CommandInvocationHashBindsEveryImmutableEnvelopeField()
+ {
+ var request = new EveSurfaceCommandRequest(
+ "provider",
+ "surface",
+ new CultMeshOperationInvocationDescriptor(
+ "launch",
+ "launch.v1",
+ new CultMeshRouteHint(CultMeshLocalityKind.Network, "commands"),
+ "command-1"),
+ CultMesh.OperationPayload(("shipId", "ship:one")),
+ DateTimeOffset.Parse("2026-08-19T00:00:00Z"),
+ "pilot:one",
+ "eve:commands",
+ EveCommandReceiptDocument.SchemaId);
+ var same = new EveSurfaceCommandRequest(
+ request.Schema,
+ request.ProviderId,
+ request.SurfaceId,
+ request.OperationRecord,
+ new Dictionary(request.PayloadFields, StringComparer.Ordinal),
+ request.IssuedAt,
+ request.ClientId,
+ request.CommandBoundary,
+ request.ReceiptSchema);
+ var changedPayload = new EveSurfaceCommandRequest(
+ request.ProviderId,
+ request.SurfaceId,
+ request.Operation,
+ CultMesh.OperationPayload(("shipId", "ship:two")),
+ request.IssuedAt,
+ request.ClientId,
+ request.CommandBoundary,
+ request.ReceiptSchema);
+
+ Assert.That(EveCommandInvocationHash.Compute(same), Is.EqualTo(EveCommandInvocationHash.Compute(request)));
+ Assert.That(EveCommandInvocationHash.Compute(changedPayload), Is.Not.EqualTo(EveCommandInvocationHash.Compute(request)));
+ }
+
[Test]
public void LegacySevenFieldSurfaceDocumentDeserializes()
{