diff --git a/tests/GameCult.Networking.Tests/CultNetClientWriteCompletionTests.cs b/tests/GameCult.Networking.Tests/CultNetClientWriteCompletionTests.cs
new file mode 100644
index 00000000..8641a082
--- /dev/null
+++ b/tests/GameCult.Networking.Tests/CultNetClientWriteCompletionTests.cs
@@ -0,0 +1,100 @@
+#nullable enable
+using System;
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+
+namespace GameCult.Networking.Tests
+{
+ ///
+ /// Heimdall's invariant: no client treats a reliable-transport ack as write admission. A put completes
+ /// only on its result frame (write_not_authorized, or the success reply). The C# schema client has no
+ /// completion call: SendCultNet returns once the put is queued, and the outcome reaches the caller only
+ /// as the result frame, through OnCultNet. These tests serve it a peer that acknowledges the put and
+ /// refuses it, the ack before the refusal and after it, and require the refusal to arrive as the outcome.
+ ///
+ public class CultNetClientWriteCompletionTests
+ {
+ private const uint ConnectionId = 0x10203197;
+
+ // Acknowledges the first reliable schema frame it receives and answers it with write_not_authorized,
+ // the second packet 150 ms after the first. The refusal carries no acknowledgement of its own.
+ private static Task RefusingPeer(Socket socket, bool refusalFirst, CancellationToken stop)
+ {
+ return Task.Run(() =>
+ {
+ var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, ResendDelayMs = 25 });
+ var buffer = new byte[65535];
+ EndPoint from = new IPEndPoint(IPAddress.Any, 0);
+ var answered = false;
+ while (!stop.IsCancellationRequested)
+ {
+ if (!socket.Poll(10_000, SelectMode.SelectRead))
+ continue;
+ var received = socket.ReceiveFrom(buffer, ref from);
+ var packet = CultNetRudpPacketCodec.Decode(buffer.AsSpan(0, received).ToArray());
+ void Send(CultNetRudpPacket p) => socket.SendTo(CultNetRudpPacketCodec.Encode(p), from);
+ if (packet.PacketType == CultNetRudpPacketType.Connect)
+ {
+ Send(session.AcceptConnect(packet));
+ continue;
+ }
+ var result = session.Receive(packet, 1);
+ if (result.Reply != null)
+ Send(result.Reply);
+ if (answered || result.Delivered.Count == 0)
+ continue;
+ answered = true;
+ var ack = session.CreateAckForReceived(packet.Sequence);
+ var refusal = session.Send("schema", CultNetSchemaMessageSerialization.Serialize(new CultNetErrorMessage
+ {
+ Error = "write_not_authorized: the catalog refused the document",
+ Code = "write_not_authorized"
+ }), new CultNetRudpSendOptions { Reliable = true, Ordered = true });
+ refusal.Ack = 0;
+ refusal.AckMask = 0;
+ if (refusalFirst)
+ {
+ Send(refusal);
+ Thread.Sleep(150);
+ Send(ack);
+ }
+ else
+ {
+ Send(ack);
+ Thread.Sleep(150);
+ Send(refusal);
+ }
+ }
+ });
+ }
+
+ [TestCase(false, TestName = "AckThenRefusal")]
+ [TestCase(true, TestName = "RefusalThenAck")]
+ public async Task ARefusedPutIsReportedRefusedWhateverTheOrderOfItsAck(bool refusalFirst)
+ {
+ using var serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
+ serverSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ using var stop = new CancellationTokenSource();
+ var peer = RefusingPeer(serverSocket, refusalFirst, stop.Token);
+ using var client = new RudpCultNetSchemaClient("completion", ConnectionId);
+ var outcomes = new BlockingCollection();
+ client.OnCultNet(outcomes.Add);
+ client.Connect("127.0.0.1", ((IPEndPoint)serverSocket.LocalEndPoint!).Port);
+ var deadline = DateTime.UtcNow.AddSeconds(3);
+ while (!client.Connected && DateTime.UtcNow < deadline)
+ await Task.Delay(5);
+ Assert.That(client.Connected, "fixture: the client connected");
+
+ client.SendCultNet(new CultNetDocumentPutRawMessage { MessageId = "completion" });
+
+ Assert.That(outcomes.TryTake(out var refusal, 3000), Is.True, "the result frame reached the caller");
+ Assert.That(refusal!.Code, Is.EqualTo("write_not_authorized"));
+ stop.Cancel();
+ await peer;
+ }
+ }
+}