You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iggy.apache.org by lz...@apache.org on 2026/08/02 13:22:54 UTC

(iggy) 01/01: feat(vsr): implement VSR wire protocol support

This is an automated email from the ASF dual-hosted git repository.

lukaszzborek pushed a commit to branch dotnet-vsr
in repository https://gitbox.apache.org/repos/asf/iggy.git

commit 6aded8378ca6fb2f08820f7d4c1a658e7f416782
Author: Ɓukasz Zborek <lu...@gmail.com>
AuthorDate: Sat Aug 1 15:48:35 2026 +0200

    feat(vsr): implement VSR wire protocol support
---
 .github/actions/csharp-dotnet/pre-merge/action.yml |   55 +-
 .github/config/components.yml                      |    2 +-
 .github/workflows/_test.yml                        |    2 +-
 .github/workflows/coverage-baseline.yml            |   40 +-
 foreign/csharp/Directory.Packages.props            |    1 +
 .../Attributes/ServerAttributes.cs                 |   50 +
 .../ClusterRedirectionTests.cs                     |    2 +
 .../ConsumerGroupTests.cs                          |   11 +-
 .../FetchMessagesTests.cs                          |    3 +
 .../Fixtures/IggyClusterFixture.cs                 |   16 +-
 .../Fixtures/IggyServerFixture.cs                  |  290 ++++--
 .../Fixtures/IggyTlsServerFixture.cs               |    5 -
 .../Fixtures/VsrCluster.cs                         |  297 ++++++
 .../FlushMessagesTests.cs                          |   12 +-
 .../HeaderEncryptionIntegrationTests.cs            |   74 +-
 .../Helpers/Eventually.cs                          |   43 +
 .../IggyConsumerTests.cs                           |   95 +-
 .../IggyPublisherTests.cs                          |   94 +-
 .../IggyTlsConnectionTests.cs                      |    8 +-
 .../IggyTypedConsumerTests.cs                      |   20 +-
 .../IggyTypedPublisherTests.cs                     |    8 +-
 .../MessageEncryptionIntegrationTests.cs           |    4 +-
 .../Iggy_SDK.Tests.Integration/OffsetTests.cs      |   27 +-
 .../Iggy_SDK.Tests.Integration/PartitionsTests.cs  |    6 +-
 .../PersonalAccessTokenTests.cs                    |    2 +-
 .../Iggy_SDK.Tests.Integration/RawCommandTests.cs  |   12 +-
 .../Iggy_SDK.Tests.Integration/SegmentsTests.cs    |   12 +-
 .../Iggy_SDK.Tests.Integration/StreamsTests.cs     |    5 +-
 .../Iggy_SDK.Tests.Integration/SystemTests.cs      |   17 +-
 .../Iggy_SDK.Tests.Integration/TopicsTests.cs      |   37 +-
 .../Iggy_SDK.Tests.Integration/UsersTests.cs       |   16 +-
 .../Vsr/VsrConsumerGroupTests.cs                   |  230 +++++
 .../Vsr/VsrHandshakeTests.cs                       |  167 +++
 .../Vsr/VsrMessagingTests.cs                       |  167 +++
 .../Vsr/VsrMetadataTests.cs                        |  134 +++
 .../Iggy_SDK/Configuration/AutoLoginSettings.cs    |   15 +
 .../Configuration/IggyClientConfigurator.cs        |   12 +
 .../Iggy_SDK/Consumers/IggyConsumer.Rented.cs      |    8 +-
 foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs  |   10 +-
 .../Iggy_SDK/Consumers/IggyConsumerBuilder.cs      |   18 +-
 .../Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs   |   11 +-
 .../Iggy_SDK/Consumers/IggyConsumerConfig.cs       |    7 +
 .../WireProtocol.cs}                               |   19 +-
 .../Exceptions/IggyInvalidStatusCodeException.cs   |   12 +-
 .../VsrRequestOutcomeUnknownException.cs           |   33 +
 ...eException.cs => VsrSessionEvictedException.cs} |   22 +-
 .../csharp/Iggy_SDK/Factory/IggyClientFactory.cs   |   31 +
 foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs  |    5 +-
 .../Implementations/HttpMessageStream.cs           |    2 +-
 .../Implementations/TcpMessageStream.Vsr.cs        | 1062 ++++++++++++++++++++
 .../IggyClient/Implementations/TcpMessageStream.cs |  332 ++++--
 foreign/csharp/Iggy_SDK/Iggy_SDK.csproj            |    1 +
 .../Publishers/BackgroundMessageProcessor.cs       |   43 +-
 .../csharp/Iggy_SDK/Publishers/IggyPublisher.cs    |    2 +-
 .../Iggy_SDK/Publishers/IggyPublisherBuilder.cs    |   16 +-
 .../Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs |   10 +-
 .../Iggy_SDK/Publishers/IggyPublisherConfig.cs     |    7 +
 foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs      |    3 +
 foreign/csharp/Iggy_SDK/Utils/ServerAddress.cs     |  139 +++
 .../Command2.cs}                                   |   20 +-
 foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs    |  223 ++++
 .../Iggy_SDK/Vsr/ConsumerGroupClientState.cs       |  301 ++++++
 foreign/csharp/Iggy_SDK/Vsr/CredentialBounds.cs    |   66 ++
 .../AutoLoginSettings.cs => Vsr/EvictionReason.cs} |   37 +-
 foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs       |  168 ++++
 foreign/csharp/Iggy_SDK/Vsr/SyncConsumerGroup.cs   |   61 ++
 foreign/csharp/Iggy_SDK/Vsr/VsrError.cs            |   61 ++
 foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs           |  156 +++
 foreign/csharp/Iggy_SDK/Vsr/VsrNamespace.cs        |  251 +++++
 foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs        |  202 ++++
 foreign/csharp/Iggy_SDK/Vsr/VsrReplyDecoder.cs     |  206 ++++
 .../ClientTests/IggyClientFactoryTests.cs          |  114 +++
 .../ConsumerTests/IggyConsumerBuilderTests.cs      |   54 +
 .../PublisherTests/IggyPublisherBuilderTests.cs    |   75 ++
 .../VsrTests/ConsensusSessionTests.cs              |  218 ++++
 .../VsrTests/ConsumerGroupClientStateTests.cs      |  176 ++++
 .../VsrTests/CredentialBoundsTests.cs              |   72 ++
 .../Iggy_SDK_Tests/VsrTests/LoginRegisterTests.cs  |  171 ++++
 .../Iggy_SDK_Tests/VsrTests/ServerAddressTests.cs  |   85 ++
 .../VsrTests/SyncConsumerGroupTests.cs             |   93 ++
 .../Iggy_SDK_Tests/VsrTests/VsrHeaderTests.cs      |  292 ++++++
 .../Iggy_SDK_Tests/VsrTests/VsrNamespaceTests.cs   |  282 ++++++
 .../Iggy_SDK_Tests/VsrTests/VsrOperationTests.cs   |  167 +++
 .../VsrTests/VsrProtocolDriftTests.cs              |  849 ++++++++++++++++
 .../VsrTests/VsrReplyDecoderTests.cs               |  293 ++++++
 .../Iggy_SDK_Tests/VsrTests/VsrTestPayloads.cs     |  102 ++
 foreign/csharp/README.md                           |  120 ++-
 87 files changed, 8122 insertions(+), 577 deletions(-)

diff --git a/.github/actions/csharp-dotnet/pre-merge/action.yml b/.github/actions/csharp-dotnet/pre-merge/action.yml
index 143a15700..697f60292 100644
--- a/.github/actions/csharp-dotnet/pre-merge/action.yml
+++ b/.github/actions/csharp-dotnet/pre-merge/action.yml
@@ -21,7 +21,7 @@ description: .NET pre-merge testing github iggy actions
 
 inputs:
   task:
-    description: "Task to run (lint, test, build, e2e)"
+    description: "Task to run (lint, test, build, e2e, e2e-vsr)"
     required: true
 
 runs:
@@ -33,7 +33,7 @@ runs:
         dotnet-version: "10.0.x"
 
     - name: Setup Rust with cache
-      if: inputs.task == 'test' || inputs.task == 'e2e'
+      if: inputs.task == 'test' || inputs.task == 'e2e' || inputs.task == 'e2e-vsr'
       uses: ./.github/actions/utils/setup-rust-with-cache
 
     - name: Restore dependencies
@@ -90,6 +90,51 @@ runs:
       env:
         IGGY_SERVER_DOCKER_IMAGE: ${{ steps.docker_build.outputs.docker_image }}
         IGGY_TEST_LOGS_DIR: ./reports/container-logs
+        # The suite runs once per server; this job only built the classic image.
+        IGGY_TEST_SERVER: classic
+      run: |
+        dotnet test --project Iggy_SDK.Tests.Integration \
+          --no-build \
+          --verbosity normal \
+          --coverage \
+          --coverage-output-format cobertura \
+          --coverage-output coverage.cobertura.xml \
+          --results-directory ./reports \
+          -- --report-trx --retry-failed-tests 3
+      shell: bash
+
+    - name: Build iggy-server-ng Docker image
+      if: inputs.task == 'e2e-vsr'
+      shell: bash
+      run: |
+        set -euo pipefail
+        IMAGE_TAG="iggy-server-ng:test"
+        SERVER_BINARY="target/debug/iggy-server-ng"
+        CLI_BINARY="target/debug/iggy"
+
+        # server-ng only speaks the VSR wire protocol when built with the vsr feature.
+        cargo build --locked --features vsr --bin iggy-server-ng --bin iggy
+        ls -lh "$SERVER_BINARY" "$CLI_BINARY"
+
+        docker build \
+          -f core/server-ng/Dockerfile \
+          --target runtime-prebuilt \
+          -t "$IMAGE_TAG" \
+          --build-arg PREBUILT_IGGY_SERVER_NG="$SERVER_BINARY" \
+          --build-arg PREBUILT_IGGY_CLI="$CLI_BINARY" \
+          .
+
+        echo "IGGY_SERVER_NG_DOCKER_IMAGE=$IMAGE_TAG" >> "$GITHUB_ENV"
+
+    - name: Run VSR integration tests
+      if: inputs.task == 'e2e-vsr'
+      working-directory: foreign/csharp
+      env:
+        IGGY_TEST_LOGS_DIR: ./reports/container-logs
+        # Runs the whole suite plus the server-ng-only tests against a two-node iggy-server-ng cluster the
+        # fixture starts, so every case commits through consensus. TCP only on this leg, framed with the VSR
+        # wire protocol.
+        IGGY_TEST_SERVER: ng
       run: |
         dotnet test --project Iggy_SDK.Tests.Integration \
           --no-build \
@@ -102,7 +147,7 @@ runs:
       shell: bash
 
     - name: Collect container logs
-      if: inputs.task == 'e2e' && always()
+      if: (inputs.task == 'e2e' || inputs.task == 'e2e-vsr') && always()
       shell: bash
       run: |
         mkdir -p foreign/csharp/reports/container-logs
@@ -110,9 +155,9 @@ runs:
 
     - name: Upload Test Results
       uses: actions/upload-artifact@v7
-      if: inputs.task == 'e2e' && always()
+      if: (inputs.task == 'e2e' || inputs.task == 'e2e-vsr') && always()
       with:
-        name: dotnet-test-results
+        name: dotnet-test-results-${{ inputs.task }}
         path: foreign/csharp/reports
         retention-days: 7
 
diff --git a/.github/config/components.yml b/.github/config/components.yml
index a2ec096d5..43ae4c2ca 100644
--- a/.github/config/components.yml
+++ b/.github/config/components.yml
@@ -281,7 +281,7 @@ components:
     paths:
       - "foreign/csharp/**"
       - "examples/csharp/**"
-    tasks: ["lint", "test", "build", "e2e"]
+    tasks: ["lint", "test", "build", "e2e", "e2e-vsr"]
 
   sdk-cpp:
     depends_on:
diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml
index 80edc184b..fb1764bea 100644
--- a/.github/workflows/_test.yml
+++ b/.github/workflows/_test.yml
@@ -197,7 +197,7 @@ jobs:
           override_pr: ${{ github.event.pull_request.number }}
 
       - name: Upload C# coverage to Codecov
-        if: inputs.component == 'sdk-csharp' && (inputs.task == 'test' || inputs.task == 'e2e')
+        if: inputs.component == 'sdk-csharp' && (inputs.task == 'test' || inputs.task == 'e2e' || inputs.task == 'e2e-vsr')
         uses: codecov/codecov-action@v7.0.0
         with:
           token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/coverage-baseline.yml b/.github/workflows/coverage-baseline.yml
index 1f580db3d..b9c6cfb29 100644
--- a/.github/workflows/coverage-baseline.yml
+++ b/.github/workflows/coverage-baseline.yml
@@ -151,7 +151,7 @@ jobs:
   csharp-coverage:
     name: C# coverage baseline
     runs-on: ubuntu-latest
-    timeout-minutes: 30
+    timeout-minutes: 60
     steps:
       - uses: actions/checkout@v7.0.1
 
@@ -195,6 +195,7 @@ jobs:
         working-directory: foreign/csharp
         env:
           IGGY_SERVER_DOCKER_IMAGE: ${{ steps.docker_build.outputs.docker_image }}
+          IGGY_TEST_SERVER: classic
         run: |
           dotnet test \
             --project Iggy_SDK.Tests.Integration \
@@ -205,6 +206,43 @@ jobs:
             --coverage-output coverage.cobertura.xml \
             --results-directory ./reports/integration
 
+      - name: Build iggy-server-ng Docker image
+        shell: bash
+        run: |
+          set -euo pipefail
+          IMAGE_TAG="iggy-server-ng:test"
+          SERVER_BINARY="target/debug/iggy-server-ng"
+          CLI_BINARY="target/debug/iggy"
+
+          # server-ng only speaks the VSR wire protocol when built with the vsr feature.
+          cargo build --locked --features vsr --bin iggy-server-ng --bin iggy
+
+          docker build \
+            -f core/server-ng/Dockerfile \
+            --target runtime-prebuilt \
+            -t "$IMAGE_TAG" \
+            --build-arg PREBUILT_IGGY_SERVER_NG="$SERVER_BINARY" \
+            --build-arg PREBUILT_IGGY_CLI="$CLI_BINARY" \
+            .
+
+          echo "IGGY_SERVER_NG_DOCKER_IMAGE=$IMAGE_TAG" >> "$GITHUB_ENV"
+
+      # The PR side uploads its e2e-vsr run under the same csharp flag, so a baseline without this leg
+      # compares every VSR line against nothing and reports the whole transport as uncovered.
+      - name: Run VSR integration tests with coverage
+        working-directory: foreign/csharp
+        env:
+          IGGY_TEST_SERVER: ng
+        run: |
+          dotnet test \
+            --project Iggy_SDK.Tests.Integration \
+            --no-build \
+            --verbosity normal \
+            --coverage \
+            --coverage-output-format cobertura \
+            --coverage-output coverage.cobertura.xml \
+            --results-directory ./reports/vsr
+
       - name: Merge coverage reports
         working-directory: foreign/csharp
         run: |
diff --git a/foreign/csharp/Directory.Packages.props b/foreign/csharp/Directory.Packages.props
index 9c6acde02..fb1f9ce5d 100644
--- a/foreign/csharp/Directory.Packages.props
+++ b/foreign/csharp/Directory.Packages.props
@@ -35,6 +35,7 @@
         <PackageVersion Include="Moq" Version="4.20.72" />
         <PackageVersion Include="Reqnroll.xunit.v3" Version="3.3.4" />
         <PackageVersion Include="Shouldly" Version="4.3.0" />
+        <PackageVersion Include="System.IO.Hashing" Version="10.0.0" />
         <PackageVersion Include="Testcontainers" Version="4.13.0" />
         <PackageVersion Include="TUnit" Version="1.61.15" />
         <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5">
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Attributes/ServerAttributes.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Attributes/ServerAttributes.cs
new file mode 100644
index 000000000..c15c3ad48
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Attributes/ServerAttributes.cs
@@ -0,0 +1,50 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Tests.Integrations.Fixtures;
+
+namespace Apache.Iggy.Tests.Integrations.Attributes;
+
+/// <summary>
+///     The operation is not served by iggy-server-ng yet, so the test only runs against the classic server.
+///     The reason names the gap.
+/// </summary>
+internal class SkipServerNgAttribute(string reason) : SkipAttribute($"Skipped for server-ng: {reason}")
+{
+    public override Task<bool> ShouldSkip(TestRegisteredContext context)
+    {
+        return Task.FromResult(IggyServerFixture.IsServerNg);
+    }
+}
+
+/// <summary>Pins a test to iggy-server-ng: a classic run has no cluster to exercise.</summary>
+internal class RequiresServerNgAttribute() : SkipAttribute("Requires IGGY_TEST_SERVER=ng")
+{
+    public override Task<bool> ShouldSkip(TestRegisteredContext context)
+    {
+        return Task.FromResult(!IggyServerFixture.IsServerNg);
+    }
+}
+
+/// <summary>Pins a test to the classic server, which owns the only image it can start.</summary>
+internal class RequiresClassicServerAttribute() : SkipAttribute("Requires IGGY_TEST_SERVER=classic")
+{
+    public override Task<bool> ShouldSkip(TestRegisteredContext context)
+    {
+        return Task.FromResult(IggyServerFixture.IsServerNg);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs
index 1eafa8ea1..9a6ea5b9c 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ClusterRedirectionTests.cs
@@ -19,11 +19,13 @@ using Apache.Iggy.Configuration;
 using Apache.Iggy.Contracts;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Factory;
+using Apache.Iggy.Tests.Integrations.Attributes;
 using Apache.Iggy.Tests.Integrations.Fixtures;
 using Shouldly;
 
 namespace Apache.Iggy.Tests.Integrations;
 
+[RequiresClassicServer]
 public class ClusterRedirectionTests
 {
     [ClassDataSource<IggyClusterFixture>(Shared = SharedType.PerAssembly)]
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs
index ee13df7d7..c6ed9ed56 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/ConsumerGroupTests.cs
@@ -81,8 +81,8 @@ public class ConsumerGroupTests
         var cg = await client.CreateConsumerGroupAsync(Identifier.String(streamName),
             Identifier.String(TopicName), GroupName);
 
-        var response = await client.GetConsumerGroupByIdAsync(
-            Identifier.String(streamName), Identifier.String(TopicName),
+        var response = await client.GetConsumerGroupByIdAsync(Identifier.String(streamName),
+            Identifier.String(TopicName),
             Identifier.Numeric(cg!.Id));
 
         response.ShouldNotBeNull();
@@ -194,15 +194,14 @@ public class ConsumerGroupTests
         var clients = new List<IIggyClient>();
         for (var i = 0; i < 2; i++)
         {
-            var memberClient = await Fixture.CreateClient(Protocol.Tcp);
+            var memberClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
             clients.Add(memberClient);
-            await memberClient.LoginUserAsync("iggy", "iggy");
             await memberClient.JoinConsumerGroupAsync(Identifier.String(streamName),
                 Identifier.String(TopicName), Identifier.Numeric(cg!.Id));
         }
 
-        var response = await client.GetConsumerGroupByIdAsync(
-            Identifier.String(streamName), Identifier.String(TopicName),
+        var response = await client.GetConsumerGroupByIdAsync(Identifier.String(streamName),
+            Identifier.String(TopicName),
             Identifier.Numeric(cg!.Id));
 
         response.ShouldNotBeNull();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs
index d75d95cee..f32e1965c 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/FetchMessagesTests.cs
@@ -23,6 +23,7 @@ using Apache.Iggy.Headers;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.Kinds;
 using Apache.Iggy.Messages;
+using Apache.Iggy.Tests.Integrations.Attributes;
 using Apache.Iggy.Tests.Integrations.Fixtures;
 using Shouldly;
 using Partitioning = Apache.Iggy.Kinds.Partitioning;
@@ -90,6 +91,8 @@ public class FetchMessagesTests
 
     [Test]
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    [SkipServerNg(
+        "server-ng replies the empty-poll shape for an unresolved topic; a zero-byte error body would break the poll decoder")]
     public async Task PollMessages_InvalidTopic_Should_Throw_InvalidResponse(Protocol protocol)
     {
         var (client, streamName) = await CreateStreamWithMessages(protocol);
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs
index 73c33edea..43725b196 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyClusterFixture.cs
@@ -39,12 +39,6 @@ public class IggyClusterFixture : IAsyncInitializer, IAsyncDisposable
     private const ushort PortRangeSize = 100;
     private static readonly ushort BasePort = (ushort)(30000 + Environment.Version.Major * 100);
     private static readonly ushort EndPort = (ushort)(BasePort + PortRangeSize);
-
-    // Listeners only need to outlive the eight ReservePort() calls in the
-    // constructor so we don't pick the same port twice within one fixture.
-    // Partitioned ranges already guarantee sibling processes can't race us, so
-    // we can release them as soon as picking is done.
-    private readonly List<TcpListener> _portReservations = [];
     private readonly IContainer _followerContainer;
     private readonly ushort _followerHttpPort;
     private readonly ushort _followerQuicPort;
@@ -60,6 +54,12 @@ public class IggyClusterFixture : IAsyncInitializer, IAsyncDisposable
 
     private readonly INetwork _network;
 
+    // Listeners only need to outlive the eight ReservePort() calls in the
+    // constructor so we don't pick the same port twice within one fixture.
+    // Partitioned ranges already guarantee sibling processes can't race us, so
+    // we can release them as soon as picking is done.
+    private readonly List<TcpListener> _portReservations = [];
+
     private string DockerImage =>
         Environment.GetEnvironmentVariable("IGGY_SERVER_DOCKER_IMAGE") ?? "apache/iggy:edge";
 
@@ -108,7 +108,7 @@ public class IggyClusterFixture : IAsyncInitializer, IAsyncDisposable
             ["IGGY_CLUSTER_NODES_1_PORTS_TCP"] = _followerTcpPort.ToString(),
             ["IGGY_CLUSTER_NODES_1_PORTS_QUIC"] = _followerQuicPort.ToString(),
             ["IGGY_CLUSTER_NODES_1_PORTS_HTTP"] = _followerHttpPort.ToString(),
-            ["IGGY_CLUSTER_NODES_1_PORTS_WEBSOCKET"] = _followerWsPort.ToString(),
+            ["IGGY_CLUSTER_NODES_1_PORTS_WEBSOCKET"] = _followerWsPort.ToString()
         };
 
         _leaderContainer = new ContainerBuilder(DockerImage)
@@ -183,7 +183,7 @@ public class IggyClusterFixture : IAsyncInitializer, IAsyncDisposable
 
     private ushort ReservePort()
     {
-        for (ushort candidate = BasePort; candidate < EndPort; candidate++)
+        for (var candidate = BasePort; candidate < EndPort; candidate++)
         {
             try
             {
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
index 8c6718b60..1022565af 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyServerFixture.cs
@@ -29,15 +29,40 @@ namespace Apache.Iggy.Tests.Integrations.Fixtures;
 
 public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
 {
+    /// <summary>
+    ///     Server the whole suite runs against: <c>classic</c> (default) or <c>ng</c>. The two servers ship as
+    ///     separate images, so the choice is a property of the run rather than of a test: CI runs the suite once
+    ///     per value, and only the second run frames TCP with the VSR wire protocol.
+    /// </summary>
+    private const string ServerVariable = "IGGY_TEST_SERVER";
+
     private readonly string _containerId = Guid.NewGuid().ToString();
-    protected IContainer? IggyContainer;
+
+    private readonly IContainer _iggyContainer;
+    private readonly HashSet<IContainer> _started = [];
+    private readonly SemaphoreSlim _startGate = new(1, 1);
+
+    private VsrCluster? _serverNgCluster;
 
     /// <summary>
     ///     Docker image to use. Can be overridden via IGGY_SERVER_DOCKER_IMAGE environment variable
-    ///     or by subclasses. Defaults to apache/iggy:edge if not specified.
+    ///     or by subclasses. Defaults to the locally built <c>iggy-server:test</c>; build it with
+    ///     <c>docker build -f Dockerfile -t iggy-server:test .</c> from the repository root, or point
+    ///     IGGY_SERVER_DOCKER_IMAGE at a published image such as <c>apache/iggy:edge</c>.
     /// </summary>
-    private string DockerImage =>
-        Environment.GetEnvironmentVariable("IGGY_SERVER_DOCKER_IMAGE") ?? "apache/iggy:edge";
+    protected virtual string DockerImage =>
+        Environment.GetEnvironmentVariable("IGGY_SERVER_DOCKER_IMAGE") ?? "iggy-server:test";
+
+    /// <summary>
+    ///     Image used when the run targets server-ng. Built from <c>core/server-ng/Dockerfile</c>. It comes up as
+    ///     a <see cref="VsrCluster" />, so every test commits through real replication.
+    /// </summary>
+    protected virtual string ServerNgDockerImage =>
+        Environment.GetEnvironmentVariable("IGGY_SERVER_NG_DOCKER_IMAGE") ?? "iggy-server-ng:test";
+
+    /// <summary>The run targets iggy-server-ng, so both protocols dial the VSR cluster instead of the classic image.</summary>
+    public static bool IsServerNg =>
+        string.Equals(Environment.GetEnvironmentVariable(ServerVariable), "ng", StringComparison.OrdinalIgnoreCase);
 
     /// <summary>
     ///     Environment variables for the container. Override in subclasses to customize.
@@ -70,7 +95,118 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
 
     public IggyServerFixture()
     {
-        var builder = new ContainerBuilder(DockerImage)
+        _iggyContainer = BuildContainer(DockerImage, _containerId);
+    }
+
+    public async ValueTask DisposeAsync()
+    {
+        await StopContainerAsync(_iggyContainer, "iggy-server");
+
+        if (_serverNgCluster != null)
+        {
+            await _serverNgCluster.DisposeAsync();
+        }
+    }
+
+    /// <summary>
+    ///     Containers start on first use: a run scoped to one server must not pay for - or fail on - the image
+    ///     the job never built.
+    /// </summary>
+    public Task InitializeAsync()
+    {
+        return Task.CompletedTask;
+    }
+
+    /// <summary>
+    ///     Under VSR the register handshake is the login, so auto-login on top of the explicit one would register
+    ///     twice on the same connection: the server answers the second one by replaying the binding it already
+    ///     holds, and the client then carries a client id the server never bound, which consumer-group
+    ///     membership is keyed by.
+    /// </summary>
+    public async Task<IIggyClient> CreateAuthenticatedClient(Protocol protocol, string userName = "iggy",
+        string password = "iggy", IMessageEncryptor? encryptor = null)
+    {
+        var client = await CreateClient(protocol, WireProtocolFor(protocol) == WireProtocol.Classic,
+            encryptor: encryptor, userName: userName, password: password);
+        await client.LoginUserAsync(userName, password);
+
+        return client;
+    }
+
+    /// <summary>
+    ///     A connected client that has not logged in, so the caller owns the handshake.
+    /// </summary>
+    public async Task<IIggyClient> CreateUnauthenticatedClient(Protocol protocol)
+    {
+        return await CreateClient(protocol);
+    }
+
+    /// <summary>
+    ///     <paramref name="address" /> overrides the container address, so a test can dial the server through a
+    ///     proxy while keeping the rest of the configuration identical.
+    /// </summary>
+    public async Task<IIggyClient> CreateClient(Protocol protocol, bool autoLogin = false, bool connect = true,
+        IMessageEncryptor? encryptor = null, string? address = null, string userName = "iggy",
+        string password = "iggy")
+    {
+        var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
+        {
+            BaseAddress = address ?? await GetIggyAddressAsync(protocol),
+            Protocol = protocol,
+            WireProtocol = WireProtocolFor(protocol),
+            ReconnectionSettings = new ReconnectionSettings { Enabled = true },
+            AutoLoginSettings = new AutoLoginSettings
+            {
+                Enabled = autoLogin,
+                Username = userName,
+                Password = password
+            },
+            MessageEncryptor = encryptor
+        });
+
+        if (connect)
+        {
+            await client.ConnectAsync();
+        }
+
+        return client;
+    }
+
+    /// <summary>VSR framing exists on TCP only, so the REST surface stays classic on either server.</summary>
+    public static WireProtocol WireProtocolFor(Protocol protocol)
+    {
+        return IsServerNg && protocol == Protocol.Tcp ? WireProtocol.Vsr : WireProtocol.Classic;
+    }
+
+    public async Task<string> GetIggyAddressAsync(Protocol protocol)
+    {
+        if (IsServerNg)
+        {
+            var cluster = await EnsureServerNgClusterStartedAsync();
+
+            return protocol == Protocol.Tcp ? cluster.LeaderTcpAddress : cluster.LeaderHttpAddress;
+        }
+
+        var container = await EnsureStartedAsync();
+
+        return protocol == Protocol.Tcp
+            ? $"127.0.0.1:{container.GetMappedPublicPort(8090)}"
+            : $"http://127.0.0.1:{container.GetMappedPublicPort(3000)}";
+    }
+
+    /// <summary>
+    ///     server-ng serves REST too, but its reads default to serializable consistency, so under the suite's
+    ///     parallelism one can trail the commit it just made and a create-then-read fails. Restore the HTTP row
+    ///     once that is settled.
+    /// </summary>
+    public static IEnumerable<Func<Protocol>> ProtocolData()
+    {
+        return IsServerNg ? [() => Protocol.Tcp] : [() => Protocol.Http, () => Protocol.Tcp];
+    }
+
+    protected virtual IContainer BuildContainer(string image, string name)
+    {
+        var builder = new ContainerBuilder(image)
             .WithPortBinding(3000, true)
             .WithPortBinding(8090, true)
             .WithWaitStrategy(Wait.ForUnixContainer()
@@ -78,7 +214,7 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
                 .UntilHttpRequestIsSucceeded(request => request
                     .ForPort(3000)
                     .ForPath("/ping")))
-            .WithName(_containerId)
+            .WithName(name)
             .WithPrivileged(true)
             .WithCleanUp(true);
 
@@ -99,29 +235,59 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
             builder = builder.WithResourceMapping(mapping.Source, mapping.Destination);
         }
 
-        IggyContainer = builder.Build();
+        return builder.Build();
     }
 
-    public async ValueTask DisposeAsync()
+    private async Task<IContainer> EnsureStartedAsync()
     {
-        if (IggyContainer == null)
+        await _startGate.WaitAsync();
+        try
         {
-            return;
+            if (_started.Add(_iggyContainer))
+            {
+                await _iggyContainer.StartAsync();
+            }
+
+            return _iggyContainer;
         }
+        finally
+        {
+            _startGate.Release();
+        }
+    }
 
-        await SaveContainerLogsAsync();
-        await IggyContainer.StopAsync();
+    private async Task<VsrCluster> EnsureServerNgClusterStartedAsync()
+    {
+        await _startGate.WaitAsync();
+        try
+        {
+            if (_serverNgCluster == null)
+            {
+                var cluster = new VsrCluster(ServerNgDockerImage, _containerId, EnabledServerTraceLogs);
+                await cluster.StartAsync();
+                _serverNgCluster = cluster;
+            }
+
+            return _serverNgCluster;
+        }
+        finally
+        {
+            _startGate.Release();
+        }
     }
 
-    public virtual async Task InitializeAsync()
+    private async Task StopContainerAsync(IContainer? container, string role)
     {
-        await IggyContainer!.StartAsync();
+        if (container == null || !_started.Contains(container))
+        {
+            return;
+        }
 
-        await CreateTcpClient();
-        await CreateHttpClient();
+        await SaveContainerLogsAsync(container, role);
+        await container.StopAsync();
     }
 
-    private async Task SaveContainerLogsAsync()
+    private static async Task SaveContainerLogsAsync(IContainer container, string role)
     {
         if (string.IsNullOrEmpty(LogDirectory))
         {
@@ -132,9 +298,9 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
         {
             Directory.CreateDirectory(LogDirectory);
             var dotnetVersion = $"net{Environment.Version.Major}.{Environment.Version.Minor}";
-            var logFilePath = Path.Combine(LogDirectory, $"iggy-server-{dotnetVersion}-{_containerId}.log");
+            var logFilePath = Path.Combine(LogDirectory, $"{role}-{dotnetVersion}-{container.Name}.log");
 
-            var (stdout, stderr) = await IggyContainer!.GetLogsAsync();
+            var (stdout, stderr) = await container.GetLogsAsync();
 
             await using var writer = new StreamWriter(logFilePath);
             if (!string.IsNullOrEmpty(stdout))
@@ -151,91 +317,7 @@ public class IggyServerFixture : IAsyncInitializer, IAsyncDisposable
         }
         catch (Exception ex)
         {
-            Console.WriteLine($"Failed to save container logs: {ex.Message}");
-        }
-    }
-
-    public async Task<Dictionary<Protocol, IIggyClient>> CreateClients()
-    {
-        var dictionary = new Dictionary<Protocol, IIggyClient>();
-        dictionary[Protocol.Tcp] = await CreateTcpClient();
-        dictionary[Protocol.Http] = await CreateHttpClient();
-
-        return dictionary;
-    }
-
-    public async Task<IIggyClient> CreateAuthenticatedClient(Protocol protocol, string userName = "iggy",
-        string password = "iggy")
-    {
-        return protocol == Protocol.Tcp
-            ? await CreateTcpClient(userName, password)
-            : await CreateHttpClient(userName, password);
-    }
-
-    public async Task<IIggyClient> CreateTcpClient(string userName = "iggy", string password = "iggy",
-        bool connect = true, IMessageEncryptor? encryptor = null)
-    {
-        var client = await CreateClient(Protocol.Tcp, connect: connect, encryptor: encryptor);
-
-        if (connect)
-        {
-            await client.LoginUserAsync(userName, password);
+            Console.WriteLine($"Failed to save {role} container logs: {ex.Message}");
         }
-
-        return client;
-    }
-
-    public async Task<IIggyClient> CreateHttpClient(string userName = "iggy", string password = "iggy",
-        IMessageEncryptor? encryptor = null)
-    {
-        var client = await CreateClient(Protocol.Http, encryptor: encryptor);
-
-        await client.LoginUserAsync(userName, password);
-
-        return client;
-    }
-
-    public async Task<IIggyClient> CreateClient(Protocol protocol, Protocol? targetContainer = null,
-        bool connect = true, IMessageEncryptor? encryptor = null)
-    {
-        var address = GetIggyAddress(protocol);
-
-        var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
-        {
-            BaseAddress = address,
-            Protocol = protocol,
-            ReconnectionSettings = new ReconnectionSettings { Enabled = true },
-            AutoLoginSettings = new AutoLoginSettings
-            {
-                Enabled = true,
-                Username = "iggy",
-                Password = "iggy"
-            },
-            MessageEncryptor = encryptor
-        });
-
-        if (connect)
-        {
-            await client.ConnectAsync();
-        }
-
-        return client;
-    }
-
-    public virtual string GetIggyAddress(Protocol protocol)
-    {
-        var port = protocol == Protocol.Tcp
-            ? IggyContainer!.GetMappedPublicPort(8090)
-            : IggyContainer!.GetMappedPublicPort(3000);
-
-        return protocol == Protocol.Tcp
-            ? $"127.0.0.1:{port}"
-            : $"http://127.0.0.1:{port}";
-    }
-
-    public static IEnumerable<Func<Protocol>> ProtocolData()
-    {
-        yield return () => Protocol.Http;
-        yield return () => Protocol.Tcp;
     }
 }
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs
index 0d7ff109d..abe8fda53 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/IggyTlsServerFixture.cs
@@ -42,9 +42,4 @@ public class IggyTlsServerFixture : IggyServerFixture
     [
         new("Certs", "/app/certs/")
     ];
-
-    public override async Task InitializeAsync()
-    {
-        await IggyContainer!.StartAsync();
-    }
 }
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs
new file mode 100644
index 000000000..dfe326aac
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Fixtures/VsrCluster.cs
@@ -0,0 +1,297 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Net;
+using System.Net.Sockets;
+using Docker.DotNet.Models;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using DotNet.Testcontainers.Networks;
+
+namespace Apache.Iggy.Tests.Integrations.Fixtures;
+
+/// <summary>
+///     The iggy-server-ng cluster a run scoped to that server uses. A single node commits with a quorum of
+///     one, so it would exercise the wire protocol without ever replicating; a real roster puts every test
+///     through consensus instead.
+/// </summary>
+internal sealed class VsrCluster : IAsyncDisposable
+{
+    private const int NodeCount = 2;
+    private const string ClusterName = "test-vsr-cluster";
+
+    // Host-port pool, partitioned per .NET major the same way IggyClusterFixture partitions its own, and
+    // disjoint from it so both fixtures can be live in one process:
+    //   net8.0  - 29800..29899
+    //   net10.0 - 30000..30099
+    // Ten ports per cluster (five transports x two nodes) fit the range with room for a third node.
+    private const ushort PortRangeSize = 100;
+
+    // Roster peers dial each other by literal IP - the ip field is parsed, not resolved - so the containers
+    // need addresses known before they start. A per-TFM subnet keeps the two `dotnet test` processes off
+    // each other's network.
+    private static readonly string Subnet = $"172.30.{Environment.Version.Major}.0/24";
+    private static readonly string Gateway = $"172.30.{Environment.Version.Major}.1";
+    private static readonly ushort BasePort = (ushort)(29000 + Environment.Version.Major * 100);
+    private static readonly ushort EndPort = (ushort)(BasePort + PortRangeSize);
+    private readonly IContainer[] _containers = new IContainer[NodeCount];
+    private readonly ushort[] _httpPorts = new ushort[NodeCount];
+    private readonly INetwork _network;
+
+    private readonly List<TcpListener> _portReservations = [];
+    private readonly ushort[] _tcpPorts = new ushort[NodeCount];
+
+    private static string? LogDirectory =>
+        Environment.GetEnvironmentVariable("IGGY_TEST_LOGS_DIR");
+
+    /// <summary>Replica 0 - primary of the initial view, and the node the tests talk to.</summary>
+    public string LeaderTcpAddress => $"127.0.0.1:{_tcpPorts[0]}";
+
+    /// <summary>The same node's REST surface, which serves classic framing rather than VSR.</summary>
+    public string LeaderHttpAddress => $"http://127.0.0.1:{_httpPorts[0]}";
+
+    public VsrCluster(string image, string idSuffix, bool traceLogs)
+    {
+        var quicPorts = new ushort[NodeCount];
+        var websocketPorts = new ushort[NodeCount];
+        var replicaPorts = new ushort[NodeCount];
+
+        try
+        {
+            for (var node = 0; node < NodeCount; node++)
+            {
+                _tcpPorts[node] = ReservePort();
+                _httpPorts[node] = ReservePort();
+                quicPorts[node] = ReservePort();
+                websocketPorts[node] = ReservePort();
+                replicaPorts[node] = ReservePort();
+            }
+        }
+        finally
+        {
+            ReleaseReservedPorts();
+        }
+
+        var networkName = $"iggy-vsr-{idSuffix}";
+        _network = new NetworkBuilder()
+            .WithName(networkName)
+            .WithCreateParameterModifier(parameters => parameters.IPAM = new IPAM
+            {
+                Config =
+                [
+                    new IPAMConfig
+                    {
+                        Subnet = Subnet,
+                        Gateway = Gateway
+                    }
+                ]
+            })
+            .Build();
+
+        var roster = new Dictionary<string, string>
+        {
+            ["IGGY_CLUSTER_ENABLED"] = "true",
+            ["IGGY_CLUSTER_NAME"] = ClusterName,
+            ["IGGY_MESSAGE_BUS_RECONNECT_PERIOD"] = "100ms"
+        };
+
+        for (var node = 0; node < NodeCount; node++)
+        {
+            roster[$"IGGY_CLUSTER_NODES_{node}_NAME"] = $"vsr-node-{node}";
+            roster[$"IGGY_CLUSTER_NODES_{node}_IP"] = NodeAddress(node);
+            roster[$"IGGY_CLUSTER_NODES_{node}_ADVERTISED_ADDRESS"] = "127.0.0.1";
+            roster[$"IGGY_CLUSTER_NODES_{node}_REPLICA_ID"] = node.ToString();
+            roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_TCP"] = _tcpPorts[node].ToString();
+            roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_HTTP"] = _httpPorts[node].ToString();
+            roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_QUIC"] = quicPorts[node].ToString();
+            roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_WEBSOCKET"] = websocketPorts[node].ToString();
+            roster[$"IGGY_CLUSTER_NODES_{node}_PORTS_TCP_REPLICA"] = replicaPorts[node].ToString();
+        }
+
+        for (var node = 0; node < NodeCount; node++)
+        {
+            var address = NodeAddress(node);
+            var builder = new ContainerBuilder(image)
+                .WithName($"iggy-vsr-{node}-{idSuffix}")
+                .WithCommand("--replica-id", node.ToString())
+                .WithNetwork(_network)
+                .WithNetworkAliases($"vsr-node-{node}")
+                .WithCreateParameterModifier(parameters => AssignStaticAddress(parameters, networkName, address))
+                // Host binding mirrors the container port so the loopback address advertised in cluster
+                // metadata resolves to the node that advertised it.
+                .WithPortBinding(_tcpPorts[node].ToString(), _tcpPorts[node].ToString())
+                .WithPortBinding(_httpPorts[node].ToString(), _httpPorts[node].ToString())
+                .WithEnvironment("IGGY_ROOT_USERNAME", "iggy")
+                .WithEnvironment("IGGY_ROOT_PASSWORD", "iggy")
+                .WithEnvironment("IGGY_SYSTEM_TOPIC_MESSAGE_EXPIRY", "10m")
+                .WithEnvironment("IGGY_SYSTEM_PATH", $"local_data_vsr_{node}")
+                .WithEnvironment("IGGY_TCP_ADDRESS", $"0.0.0.0:{_tcpPorts[node]}")
+                .WithEnvironment("IGGY_HTTP_ADDRESS", $"0.0.0.0:{_httpPorts[node]}")
+                .WithEnvironment("IGGY_QUIC_ADDRESS", $"0.0.0.0:{quicPorts[node]}")
+                .WithEnvironment("IGGY_WEBSOCKET_ADDRESS", $"0.0.0.0:{websocketPorts[node]}")
+                .WithEnvironment(roster)
+                .WithPrivileged(true)
+                .WithCleanUp(true)
+                .WithWaitStrategy(Wait.ForUnixContainer()
+                    .UntilInternalTcpPortIsAvailable(_tcpPorts[node])
+                    .UntilInternalTcpPortIsAvailable(_httpPorts[node]));
+
+            if (traceLogs)
+            {
+                builder = builder
+                    .WithEnvironment("IGGY_SYSTEM_LOGGING_LEVEL", "trace")
+                    .WithEnvironment("RUST_LOG", "trace");
+            }
+
+            _containers[node] = builder.Build();
+        }
+    }
+
+    public async ValueTask DisposeAsync()
+    {
+        for (var node = 0; node < NodeCount; node++)
+        {
+            try
+            {
+                await SaveContainerLogsAsync(_containers[node], $"iggy-server-ng-{node}");
+            }
+            catch (Exception e)
+            {
+                Console.WriteLine($"Failed to save the logs of iggy-server-ng-{node}: {e}");
+            }
+        }
+
+        foreach (var container in _containers)
+        {
+            try
+            {
+                await container.DisposeAsync();
+            }
+            catch (Exception e)
+            {
+                Console.WriteLine($"Failed to dispose an iggy-server-ng container: {e}");
+            }
+        }
+
+        try
+        {
+            await _network.DeleteAsync();
+        }
+        catch (Exception e)
+        {
+            Console.WriteLine($"Failed to delete the iggy-server-ng network: {e}");
+        }
+    }
+
+    public async Task StartAsync()
+    {
+        await _network.CreateAsync();
+        // A two-node roster needs both replicas for a quorum, so nothing commits until the pair is up.
+        await Task.WhenAll(_containers.Select(container => container.StartAsync()));
+    }
+
+    /// <summary>
+    ///     Pins the container's address on the cluster network. Testcontainers has no first-class knob for it,
+    ///     and the roster needs the address before any container starts.
+    /// </summary>
+    private static void AssignStaticAddress(CreateContainerParameters parameters, string networkName,
+        string address)
+    {
+        parameters.NetworkingConfig ??= new NetworkingConfig();
+        parameters.NetworkingConfig.EndpointsConfig ??= new Dictionary<string, EndpointSettings>();
+
+        if (!parameters.NetworkingConfig.EndpointsConfig.TryGetValue(networkName, out var endpoint))
+        {
+            endpoint = new EndpointSettings();
+            parameters.NetworkingConfig.EndpointsConfig[networkName] = endpoint;
+        }
+
+        endpoint.IPAMConfig = new EndpointIPAMConfig { IPv4Address = address };
+    }
+
+    private static string NodeAddress(int node)
+    {
+        return $"172.30.{Environment.Version.Major}.{10 + node}";
+    }
+
+    private ushort ReservePort()
+    {
+        for (var candidate = BasePort; candidate < EndPort; candidate++)
+        {
+            try
+            {
+                var listener = new TcpListener(IPAddress.Loopback, candidate);
+                listener.Start();
+                _portReservations.Add(listener);
+                return candidate;
+            }
+            catch (SocketException)
+            {
+                // Held by an earlier ReservePort() in this cluster, or by something else on the host.
+            }
+        }
+
+        throw new InvalidOperationException(
+            $"No free ports available in [{BasePort}, {EndPort}) for .NET {Environment.Version.Major}.x.");
+    }
+
+    private void ReleaseReservedPorts()
+    {
+        foreach (var listener in _portReservations)
+        {
+            listener.Stop();
+        }
+
+        _portReservations.Clear();
+    }
+
+    private static async Task SaveContainerLogsAsync(IContainer container, string role)
+    {
+        if (string.IsNullOrEmpty(LogDirectory))
+        {
+            return;
+        }
+
+        try
+        {
+            Directory.CreateDirectory(LogDirectory);
+            var dotnetVersion = $"net{Environment.Version.Major}.{Environment.Version.Minor}";
+            // Docker hands back names with a leading slash, which Path.Combine would read as a directory.
+            var containerName = container.Name.TrimStart('/');
+            var logFilePath = Path.Combine(LogDirectory, $"{role}-{dotnetVersion}-{containerName}.log");
+
+            var (stdout, stderr) = await container.GetLogsAsync();
+
+            await using var writer = new StreamWriter(logFilePath);
+            if (!string.IsNullOrEmpty(stdout))
+            {
+                await writer.WriteLineAsync("=== STDOUT ===");
+                await writer.WriteLineAsync(stdout);
+            }
+
+            if (!string.IsNullOrEmpty(stderr))
+            {
+                await writer.WriteLineAsync("=== STDERR ===");
+                await writer.WriteLineAsync(stderr);
+            }
+        }
+        catch (Exception ex)
+        {
+            Console.WriteLine($"Failed to save {role} container logs: {ex.Message}");
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs
index 778b707ba..1e4238bb0 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/FlushMessagesTests.cs
@@ -19,6 +19,7 @@ using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.Messages;
+using Apache.Iggy.Tests.Integrations.Attributes;
 using Apache.Iggy.Tests.Integrations.Fixtures;
 using Shouldly;
 using Partitioning = Apache.Iggy.Kinds.Partitioning;
@@ -55,25 +56,25 @@ public class FlushMessagesTests
 
     [Test]
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    [SkipServerNg("server-ng has no on-demand flush primitive and denies FLUSH_UNSAVED_BUFFER outright")]
     public async Task FlushUnsavedBuffer_WithFsync_Should_Flush_Successfully(Protocol protocol)
     {
         var (client, streamName, topicName) = await CreateStreamWithMessages(protocol);
 
         await Should.NotThrowAsync(() =>
-            client.FlushUnsavedBufferAsync(
-                Identifier.String(streamName),
+            client.FlushUnsavedBufferAsync(Identifier.String(streamName),
                 Identifier.String(topicName), 0, true));
     }
 
     [Test]
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
+    [SkipServerNg("server-ng has no on-demand flush primitive and denies FLUSH_UNSAVED_BUFFER outright")]
     public async Task FlushUnsavedBuffer_WithOutFsync_Should_Flush_Successfully(Protocol protocol)
     {
         var (client, streamName, topicName) = await CreateStreamWithMessages(protocol);
 
         await Should.NotThrowAsync(() =>
-            client.FlushUnsavedBufferAsync(
-                Identifier.String(streamName),
+            client.FlushUnsavedBufferAsync(Identifier.String(streamName),
                 Identifier.String(topicName), 0, false));
     }
 
@@ -84,8 +85,7 @@ public class FlushMessagesTests
         var (client, streamName, topicName) = await CreateStreamWithMessages(protocol);
 
         await Should.ThrowAsync<IggyInvalidStatusCodeException>(() =>
-            client.FlushUnsavedBufferAsync(
-                Identifier.String(streamName),
+            client.FlushUnsavedBufferAsync(Identifier.String(streamName),
                 Identifier.String(topicName), 55, false));
     }
 }
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs
index 4004a5929..9de96652d 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/HeaderEncryptionIntegrationTests.cs
@@ -45,12 +45,8 @@ public class HeaderEncryptionIntegrationTests
 
         // Publisher on an encrypting client; a plain client raw-polls the same topic to prove the wire bytes
         // stay encrypted.
-        var encryptingClient = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient(encryptor: encryptor)
-            : await Fixture.CreateHttpClient(encryptor: encryptor);
-        var plainClient = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var encryptingClient = await Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor);
+        var plainClient = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(plainClient, protocol);
         var streamId = Identifier.String(testStream.StreamId);
@@ -93,7 +89,11 @@ public class HeaderEncryptionIntegrationTests
         Dictionary<HeaderKey, HeaderValue> decryptedHeaders = BinaryMapper.MapHeaders(decryptedHeaderBytesResult);
         decryptedHeaders.Count.ShouldBe(3);
 
-        var typeHeader = decryptedHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "type"u8.ToArray() }];
+        var typeHeader = decryptedHeaders[new HeaderKey
+        {
+            Kind = HeaderKind.String,
+            Value = "type"u8.ToArray()
+        }];
         Encoding.UTF8.GetString(typeHeader.Value).ShouldBe("test-message");
     }
 
@@ -104,9 +104,7 @@ public class HeaderEncryptionIntegrationTests
         var encryptor = CreateEncryptor();
 
         // One encrypting client serves both publisher and consumer: it encrypts on send and decrypts on poll.
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient(encryptor: encryptor)
-            : await Fixture.CreateHttpClient(encryptor: encryptor);
+        var client = await Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor);
 
         var testStream = await CreateTestStream(client, protocol);
         var streamId = Identifier.String(testStream.StreamId);
@@ -162,13 +160,25 @@ public class HeaderEncryptionIntegrationTests
         received.Message.UserHeaders.ShouldNotBeNull();
         received.Message.UserHeaders!.Count.ShouldBe(3);
 
-        var batchHeader = received.Message.UserHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "batch"u8.ToArray() }];
+        var batchHeader = received.Message.UserHeaders[new HeaderKey
+        {
+            Kind = HeaderKind.String,
+            Value = "batch"u8.ToArray()
+        }];
         BitConverter.ToUInt64(batchHeader.Value).ShouldBe(1UL);
 
-        var typeHeader = received.Message.UserHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "type"u8.ToArray() }];
+        var typeHeader = received.Message.UserHeaders[new HeaderKey
+        {
+            Kind = HeaderKind.String,
+            Value = "type"u8.ToArray()
+        }];
         Encoding.UTF8.GetString(typeHeader.Value).ShouldBe("test-message");
 
-        var encHeader = received.Message.UserHeaders[new HeaderKey { Kind = HeaderKind.String, Value = "encrypted"u8.ToArray() }];
+        var encHeader = received.Message.UserHeaders[new HeaderKey
+        {
+            Kind = HeaderKind.String,
+            Value = "encrypted"u8.ToArray()
+        }];
         encHeader.Value[0].ShouldBe((byte)1);
     }
 
@@ -182,17 +192,41 @@ public class HeaderEncryptionIntegrationTests
         return new Dictionary<HeaderKey, HeaderValue>
         {
             {
-                new HeaderKey { Kind = HeaderKind.String, Value = "batch"u8.ToArray() },
-                new HeaderValue { Kind = HeaderKind.Uint64, Value = BitConverter.GetBytes(1UL) }
+                new HeaderKey
+                {
+                    Kind = HeaderKind.String,
+                    Value = "batch"u8.ToArray()
+                },
+                new HeaderValue
+                {
+                    Kind = HeaderKind.Uint64,
+                    Value = BitConverter.GetBytes(1UL)
+                }
             },
             {
-                new HeaderKey { Kind = HeaderKind.String, Value = "type"u8.ToArray() },
-                new HeaderValue { Kind = HeaderKind.String, Value = "test-message"u8.ToArray() }
+                new HeaderKey
+                {
+                    Kind = HeaderKind.String,
+                    Value = "type"u8.ToArray()
+                },
+                new HeaderValue
+                {
+                    Kind = HeaderKind.String,
+                    Value = "test-message"u8.ToArray()
+                }
             },
             {
-                new HeaderKey { Kind = HeaderKind.String, Value = "encrypted"u8.ToArray() },
-                new HeaderValue { Kind = HeaderKind.Bool, Value = [1] }
-            },
+                new HeaderKey
+                {
+                    Kind = HeaderKind.String,
+                    Value = "encrypted"u8.ToArray()
+                },
+                new HeaderValue
+                {
+                    Kind = HeaderKind.Bool,
+                    Value = [1]
+                }
+            }
         };
     }
 
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/Eventually.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/Eventually.cs
new file mode 100644
index 000000000..ba5ce825d
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Helpers/Eventually.cs
@@ -0,0 +1,43 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+namespace Apache.Iggy.Tests.Integrations.Helpers;
+
+public static class Eventually
+{
+    private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(100);
+
+    /// <summary>
+    ///     Polls until the read satisfies the condition. Some server work commits ahead of the state it changes -
+    ///     server-ng applies a purge by advancing a generation that its reconciler acts on a tick later - so the
+    ///     first read after an acknowledged command can still show the old value.
+    /// </summary>
+    public static async Task<T> ReadAsync<T>(Func<Task<T>> read, Func<T, bool> condition, TimeSpan timeout)
+    {
+        var deadline = DateTime.UtcNow + timeout;
+        while (true)
+        {
+            var value = await read();
+            if (condition(value) || DateTime.UtcNow >= deadline)
+            {
+                return value;
+            }
+
+            await Task.Delay(PollInterval);
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs
index 72f7987b8..b79530cfc 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyConsumerTests.cs
@@ -38,9 +38,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithSingleConsumer_Should_Initialize_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -63,9 +61,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithConsumerGroup_Should_Initialize_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -88,19 +84,18 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_NewClient_Should_Initialize_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
-        var clientAddress = Fixture.GetIggyAddress(protocol); ;
+        var clientAddress = await Fixture.GetIggyAddressAsync(protocol);
 
         var consumer = IggyConsumerBuilder
             .Create(Identifier.String(testStream.StreamId),
                 Identifier.String(testStream.TopicId),
                 Consumer.New(2))
             .WithConnection(protocol, clientAddress, "iggy", "iggy")
+                .WithWireProtocol(IggyServerFixture.WireProtocolFor(protocol))
             .WithPollingStrategy(PollingStrategy.Next())
             .WithBatchSize(10)
             .WithConsumerGroup("test-group-init")
@@ -114,9 +109,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_CalledTwice_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -139,9 +132,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithoutInit_Should_Throw_ConsumerNotInitializedException(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -169,9 +160,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithConsumerGroup_Should_CreateGroup_WhenNotExists(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -204,9 +193,7 @@ public class IggyConsumerTests
     public async Task InitAsync_WithConsumerGroup_Should_Throw_WhenGroupNotExists_AndAutoCreateDisabled(
         Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -230,9 +217,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithConsumerGroup_Should_JoinGroup_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -261,9 +246,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DisposeAsync_Should_LeaveConsumerGroup(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -294,9 +277,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithSingleConsumer_Should_ReceiveMessages_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -339,9 +320,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithBatchSize_Should_RespectBatchSize(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -380,9 +359,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithPollingInterval_Should_RespectInterval(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -422,9 +399,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithAutoCommitAfterReceive_Should_StoreOffset(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -472,9 +447,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithAutoCommitAfterPoll_Should_StoreOffset(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -521,9 +494,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task StoreOffsetAsync_Should_StoreOffset_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -560,9 +531,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DeleteOffsetAsync_Should_DeleteOffset_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -605,9 +574,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DisposeAsync_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -629,9 +596,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DisposeAsync_CalledTwice_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -654,9 +619,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DisposeAsync_WithoutInit_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -677,9 +640,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task OnPollingError_Should_Fire_WhenPollingFails(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -728,9 +689,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithOffsetStrategy_Should_StartFromOffset(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -767,9 +726,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithFirstStrategy_Should_StartFromBeginning(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -801,9 +758,7 @@ public class IggyConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveAsync_WithLastStrategy_Should_StartFromEnd(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs
index c1028ec0d..4b1bfd44b 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyPublisherTests.cs
@@ -35,7 +35,8 @@ public class IggyPublisherTests
     [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
     public required IggyServerFixture Fixture { get; init; }
 
-    private async Task<TestStreamInfo> CreateTestStream(IIggyClient client, Protocol protocol, uint partitionsCount = 5)
+    private async Task<TestStreamInfo> CreateTestStream(IIggyClient client, Protocol protocol,
+        uint partitionsCount = 5)
     {
         var streamId = $"stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}";
         var topicId = "test_topic";
@@ -50,9 +51,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_Should_Initialize_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -70,7 +69,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_NewClient_Should_Initialize_Successfully(Protocol protocol)
     {
-        var client = Fixture.GetIggyAddress(protocol);
+        var client = await Fixture.GetIggyAddressAsync(protocol);
 
         var stream = Guid.NewGuid().ToString();
         var topic = Guid.NewGuid().ToString();
@@ -80,6 +79,7 @@ public class IggyPublisherTests
             .CreateStreamIfNotExists(stream)
             .CreateTopicIfNotExists(topic)
             .WithConnection(protocol, client, "iggy", "iggy")
+                .WithWireProtocol(IggyServerFixture.WireProtocolFor(protocol))
             .WithPartitioning(Partitioning.PartitionId(1))
             .Build();
 
@@ -91,9 +91,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_CalledTwice_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -111,9 +109,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendMessages_WithoutInit_Should_Throw_PublisherNotInitializedException(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -132,9 +128,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendMessages_Should_SendMessages_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -170,9 +164,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendMessages_WithEmptyList_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -192,9 +184,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithStreamAutoCreate_Should_CreateStream(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var streamId = $"auto_stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}";
         var topicId = "auto_topic";
@@ -221,9 +211,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithTopicAutoCreate_Should_CreateTopic(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var streamId = $"stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}";
         var topicId = "auto_topic";
@@ -252,9 +240,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithoutAutoCreate_Should_Throw_WhenStreamNotExists(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var streamId = $"nonexistent_stream_{Guid.NewGuid()}";
         var topicId = "test_topic";
@@ -272,9 +258,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task InitAsync_WithoutAutoCreate_Should_Throw_WhenTopicNotExists(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var streamId = $"stream_{Guid.NewGuid()}_{protocol.ToString().ToLowerInvariant()}";
         var topicId = "nonexistent_topic";
@@ -295,9 +279,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendMessages_WithBackgroundSending_Should_SendMessages_Successfully(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -335,9 +317,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task WaitUntilAllSends_Should_WaitForPendingMessages(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -372,9 +352,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task WaitUntilAllSends_WithoutBackgroundSending_Should_ReturnImmediately(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -395,9 +373,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendMessages_ToMultiplePartitions_Should_DistributeMessages(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -442,9 +418,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendMessages_WithBalancedPartitioning_Should_DistributeAcrossPartitions(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol, 3);
 
@@ -486,9 +460,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DisposeAsync_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -505,9 +477,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DisposeAsync_CalledTwice_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -525,9 +495,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task DisposeAsync_WithoutInit_Should_NotThrow(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -544,9 +512,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task StreamId_Should_ReturnConfiguredStreamId(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -563,9 +529,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task TopicId_Should_ReturnConfiguredTopicId(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -582,9 +546,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendMessages_LargeMessageCount_Should_HandleCorrectly(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -622,9 +584,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendAsync_RentedBatch_WithBackgroundSending_Should_RoundTrip(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
@@ -675,9 +635,7 @@ public class IggyPublisherTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task SendAsync_SingleMessageRentedBatch_WithBackgroundSending_Should_RoundTrip(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStream(client, protocol);
 
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
index 4b4200a74..e2826d148 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTlsConnectionTests.cs
@@ -19,11 +19,13 @@ using Apache.Iggy.Configuration;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.Factory;
+using Apache.Iggy.Tests.Integrations.Attributes;
 using Apache.Iggy.Tests.Integrations.Fixtures;
 using Shouldly;
 
 namespace Apache.Iggy.Tests.Integrations;
 
+[RequiresClassicServer]
 public class IggyTlsConnectionTests
 {
     [ClassDataSource<IggyTlsServerFixture>(Shared = SharedType.PerAssembly)]
@@ -34,7 +36,7 @@ public class IggyTlsConnectionTests
     {
         using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
         {
-            BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp),
+            BaseAddress = await Fixture.GetIggyAddressAsync(Protocol.Tcp),
             Protocol = Protocol.Tcp,
             ReconnectionSettings = new ReconnectionSettings { Enabled = false },
             AutoLoginSettings = new AutoLoginSettings
@@ -62,7 +64,7 @@ public class IggyTlsConnectionTests
     {
         using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
         {
-            BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp),
+            BaseAddress = await Fixture.GetIggyAddressAsync(Protocol.Tcp),
             Protocol = Protocol.Tcp,
             ReconnectionSettings = new ReconnectionSettings { Enabled = false }
         });
@@ -76,7 +78,7 @@ public class IggyTlsConnectionTests
     {
         using var client = IggyClientFactory.CreateClient(new IggyClientConfigurator
         {
-            BaseAddress = Fixture.GetIggyAddress(Protocol.Tcp),
+            BaseAddress = await Fixture.GetIggyAddressAsync(Protocol.Tcp),
             Protocol = Protocol.Tcp,
             ReconnectionSettings = new ReconnectionSettings { Enabled = false },
             AutoLoginSettings = new AutoLoginSettings
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs
index 3861ae812..460b1c8ce 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedConsumerTests.cs
@@ -38,9 +38,7 @@ public class IggyTypedConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveDeserializedAsync_Should_YieldMessages_WithCorrectData(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -77,9 +75,7 @@ public class IggyTypedConsumerTests
     public async Task ReceiveDeserializedAsync_WithoutInit_Should_Throw_ConsumerNotInitializedException(
         Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -101,9 +97,7 @@ public class IggyTypedConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveDeserializedAsync_WithAutoCommitAfterReceive_Should_StoreOffset(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -142,9 +136,7 @@ public class IggyTypedConsumerTests
     public async Task ReceiveDeserializedAsync_WithFailingDeserializer_Should_YieldDeserializationFailed(
         Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
@@ -172,9 +164,7 @@ public class IggyTypedConsumerTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task ReceiveDeserializedAsync_Should_StopCleanly_OnCancellation(Protocol protocol)
     {
-        var client = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var testStream = await CreateTestStreamWithMessages(client, protocol);
 
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs
index 64d32e40b..84c5d5081 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/IggyTypedPublisherTests.cs
@@ -249,9 +249,7 @@ public class IggyTypedPublisherTests
 
         // Encryption is configured on the client. The publisher uses an encrypting client; a plain client polls
         // to prove the wire bytes are ciphertext, then decrypts manually.
-        var encryptingClient = protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient(encryptor: encryptor)
-            : await Fixture.CreateHttpClient(encryptor: encryptor);
+        var encryptingClient = await Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor);
         var plainClient = await Client(protocol);
         var stream = await CreateTestStream(plainClient, protocol);
 
@@ -292,9 +290,7 @@ public class IggyTypedPublisherTests
 
     private async Task<IIggyClient> Client(Protocol protocol)
     {
-        return protocol == Protocol.Tcp
-            ? await Fixture.CreateTcpClient()
-            : await Fixture.CreateHttpClient();
+        return await Fixture.CreateAuthenticatedClient(protocol);
     }
 
     // Base fluent methods return the non-generic builder, so apply them as statements to keep the typed Build().
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs
index 8e29ec1ba..96426ee55 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/MessageEncryptionIntegrationTests.cs
@@ -195,9 +195,7 @@ public class MessageEncryptionIntegrationTests
 
     private Task<IIggyClient> CreateClient(Protocol protocol, IMessageEncryptor encryptor)
     {
-        return protocol == Protocol.Tcp
-            ? Fixture.CreateTcpClient(encryptor: encryptor)
-            : Fixture.CreateHttpClient(encryptor: encryptor);
+        return Fixture.CreateAuthenticatedClient(protocol, encryptor: encryptor);
     }
 
     private static AesMessageEncryptor CreateEncryptor()
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs
index 82cc193d7..f96ced773 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/OffsetTests.cs
@@ -98,15 +98,13 @@ public class OffsetTests
         // For HTTP, a separate TCP client joins (HTTP is stateless and doesn't track membership).
         if (protocol == Protocol.Tcp)
         {
-            await client.JoinConsumerGroupAsync(
-                Identifier.String(streamName),
+            await client.JoinConsumerGroupAsync(Identifier.String(streamName),
                 Identifier.String(topicName), Identifier.String("test_consumer_group"));
         }
         else
         {
-            var tcpClient = await Fixture.CreateTcpClient();
-            await tcpClient.JoinConsumerGroupAsync(
-                Identifier.String(streamName),
+            var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+            await tcpClient.JoinConsumerGroupAsync(Identifier.String(streamName),
                 Identifier.String(topicName), Identifier.String("test_consumer_group"));
         }
 
@@ -126,15 +124,13 @@ public class OffsetTests
 
         if (protocol == Protocol.Tcp)
         {
-            await client.JoinConsumerGroupAsync(
-                Identifier.String(streamName),
+            await client.JoinConsumerGroupAsync(Identifier.String(streamName),
                 Identifier.String(topicName), Identifier.String("test_consumer_group"));
         }
         else
         {
-            var tcpClient = await Fixture.CreateTcpClient();
-            await tcpClient.JoinConsumerGroupAsync(
-                Identifier.String(streamName),
+            var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+            await tcpClient.JoinConsumerGroupAsync(Identifier.String(streamName),
                 Identifier.String(topicName), Identifier.String("test_consumer_group"));
         }
 
@@ -161,15 +157,13 @@ public class OffsetTests
 
         if (protocol == Protocol.Tcp)
         {
-            await client.JoinConsumerGroupAsync(
-                Identifier.String(streamName),
+            await client.JoinConsumerGroupAsync(Identifier.String(streamName),
                 Identifier.String(topicName), Identifier.String("test_consumer_group"));
         }
         else
         {
-            var tcpClient = await Fixture.CreateTcpClient();
-            await tcpClient.JoinConsumerGroupAsync(
-                Identifier.String(streamName),
+            var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+            await tcpClient.JoinConsumerGroupAsync(Identifier.String(streamName),
                 Identifier.String(topicName), Identifier.String("test_consumer_group"));
         }
 
@@ -195,8 +189,7 @@ public class OffsetTests
         await client.CreateConsumerGroupAsync(Identifier.String(streamName),
             Identifier.String(topicName), "test_consumer_group");
 
-        await client.JoinConsumerGroupAsync(
-            Identifier.String(streamName),
+        await client.JoinConsumerGroupAsync(Identifier.String(streamName),
             Identifier.String(topicName), Identifier.String("test_consumer_group"));
 
         await client.StoreOffsetAsync(Consumer.Group("test_consumer_group"), Identifier.String(streamName),
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs
index 406b1d3ad..64e281ac4 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/PartitionsTests.cs
@@ -43,8 +43,7 @@ public class PartitionsTests
             client.CreatePartitionsAsync(Identifier.String(streamName),
                 Identifier.String(topicName), 3));
 
-        var response = await client.GetTopicByIdAsync(
-            Identifier.String(streamName), Identifier.String(topicName));
+        var response = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.String(topicName));
         response.ShouldNotBeNull();
         response.PartitionsCount.ShouldBe(4u);
     }
@@ -65,8 +64,7 @@ public class PartitionsTests
             client.DeletePartitionsAsync(Identifier.String(streamName),
                 Identifier.String(topicName), 1));
 
-        var response = await client.GetTopicByIdAsync(
-            Identifier.String(streamName), Identifier.String(topicName));
+        var response = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.String(topicName));
         response.ShouldNotBeNull();
         response.PartitionsCount.ShouldBe(3u);
     }
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs
index 2e14ff637..b8ffc39b9 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/PersonalAccessTokenTests.cs
@@ -87,7 +87,7 @@ public class PersonalAccessTokenTests
         var name = $"lgn-{Guid.NewGuid():N}"[..20];
         var response = await client.CreatePersonalAccessTokenAsync(name, Expiry);
 
-        var loginClient = await Fixture.CreateClient(protocol);
+        var loginClient = await Fixture.CreateClient(protocol, true);
         var authResponse = await loginClient.LoginWithPersonalAccessTokenAsync(response!.Token);
 
         authResponse.ShouldNotBeNull();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs
index 49d156fe4..1cbfcd059 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/RawCommandTests.cs
@@ -51,8 +51,9 @@ public class RawCommandTests
 
         foreach (var code in new uint[] { 38, 39, 40, 44, 45 })
         {
-            var exception = await Should.ThrowAsync<IggyInvalidStatusCodeException>(
-                () => client.SendBinaryRequestAsync(code, []));
+            var exception
+                = await Should.ThrowAsync<IggyInvalidStatusCodeException>(() =>
+                    client.SendBinaryRequestAsync(code, []));
             exception.StatusCode.ShouldBe(3);
         }
     }
@@ -64,8 +65,8 @@ public class RawCommandTests
     {
         var client = await Fixture.CreateAuthenticatedClient(protocol);
 
-        var exception = await Should.ThrowAsync<IggyInvalidStatusCodeException>(
-            () => client.SendBinaryRequestAsync(60_000, []));
+        var exception
+            = await Should.ThrowAsync<IggyInvalidStatusCodeException>(() => client.SendBinaryRequestAsync(60_000, []));
 
         exception.StatusCode.ShouldBe(3);
     }
@@ -77,7 +78,6 @@ public class RawCommandTests
     {
         var client = await Fixture.CreateAuthenticatedClient(protocol);
 
-        await Should.ThrowAsync<FeatureUnavailableException>(
-            () => client.SendBinaryRequestAsync(1, []));
+        await Should.ThrowAsync<FeatureUnavailableException>(() => client.SendBinaryRequestAsync(1, []));
     }
 }
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs
index eed1d01b0..64a80a390 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/SegmentsTests.cs
@@ -42,8 +42,7 @@ public class SegmentsTests
 
         // Deleting 0 segments should succeed without error (no-op)
         await Should.NotThrowAsync(() =>
-            client.DeleteSegmentsAsync(
-                Identifier.String(streamName),
+            client.DeleteSegmentsAsync(Identifier.String(streamName),
                 Identifier.String(topicName),
                 0, // partition_id (0-indexed)
                 0)); // segments_count = 0
@@ -62,8 +61,7 @@ public class SegmentsTests
         await client.CreateTopicAsync(Identifier.String(streamName), topicName, 1);
 
         await Should.ThrowAsync<FeatureUnavailableException>(() =>
-            client.DeleteSegmentsAsync(
-                Identifier.String(streamName),
+            client.DeleteSegmentsAsync(Identifier.String(streamName),
                 Identifier.String(topicName),
                 0,
                 0));
@@ -80,8 +78,7 @@ public class SegmentsTests
         await client.CreateStreamAsync(streamName);
 
         await Should.ThrowAsync<IggyInvalidStatusCodeException>(() =>
-            client.DeleteSegmentsAsync(
-                Identifier.String(streamName),
+            client.DeleteSegmentsAsync(Identifier.String(streamName),
                 Identifier.String("non-existent-topic"),
                 0, // partition_id (0-indexed)
                 1)); // segments_count
@@ -95,8 +92,7 @@ public class SegmentsTests
         var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         await Should.ThrowAsync<IggyInvalidStatusCodeException>(() =>
-            client.DeleteSegmentsAsync(
-                Identifier.String($"nonexistent-stream-{Guid.NewGuid():N}"),
+            client.DeleteSegmentsAsync(Identifier.String($"nonexistent-stream-{Guid.NewGuid():N}"),
                 Identifier.String("any-topic"),
                 0, // partition_id (0-indexed)
                 1)); // segments_count
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs
index aa47d00c8..5a990e716 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/StreamsTests.cs
@@ -20,6 +20,7 @@ using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.Messages;
 using Apache.Iggy.Tests.Integrations.Fixtures;
+using Apache.Iggy.Tests.Integrations.Helpers;
 using Shouldly;
 using Partitioning = Apache.Iggy.Kinds.Partitioning;
 
@@ -216,7 +217,9 @@ public class StreamsTests
 
         await Should.NotThrowAsync(() => client.PurgeStreamAsync(Identifier.String(streamName)));
 
-        stream = await client.GetStreamByIdAsync(Identifier.String(streamName));
+        // server-ng commits the purge by advancing a generation its reconciler acts on a tick later.
+        stream = await Eventually.ReadAsync(() => client.GetStreamByIdAsync(Identifier.String(streamName)),
+            purged => purged?.MessagesCount == 0, TimeSpan.FromSeconds(10));
         stream.ShouldNotBeNull();
         stream.MessagesCount.ShouldBe(0u);
         stream.TopicsCount.ShouldBe(1);
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs
index 3d803b4cc..32f034ced 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/SystemTests.cs
@@ -55,8 +55,7 @@ public class SystemTests
     {
         var client = await Fixture.CreateAuthenticatedClient(protocol);
 
-        var tcpClient = await Fixture.CreateClient(Protocol.Tcp);
-        await tcpClient.LoginUserAsync("iggy", "iggy");
+        var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
         var clientInfo = await tcpClient.GetMeAsync();
         clientInfo.ShouldNotBeNull();
 
@@ -76,7 +75,7 @@ public class SystemTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task GetMe_Tcp_Should_Return_MyClient(Protocol protocol)
     {
-        var client = await Fixture.CreateTcpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var me = await client.GetMeAsync();
         me.ShouldNotBeNull();
@@ -91,7 +90,7 @@ public class SystemTests
     [MethodDataSource<IggyServerFixture>(nameof(IggyServerFixture.ProtocolData))]
     public async Task GetMe_HTTP_Should_Throw_FeatureUnavailableException(Protocol protocol)
     {
-        var client = await Fixture.CreateHttpClient();
+        var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         await Should.ThrowAsync<FeatureUnavailableException>(() => client.GetMeAsync());
     }
@@ -103,8 +102,7 @@ public class SystemTests
         var client = await Fixture.CreateAuthenticatedClient(protocol);
 
         var streamName = $"sys-cg-{Guid.NewGuid():N}";
-        var tcpClient = await Fixture.CreateClient(Protocol.Tcp);
-        await tcpClient.LoginUserAsync("iggy", "iggy");
+        var tcpClient = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
 
         var stream = await tcpClient.CreateStreamAsync(streamName);
         await tcpClient.CreateTopicAsync(Identifier.String(streamName), "first_topic", 2);
@@ -159,7 +157,12 @@ public class SystemTests
         response.PartitionsCount.ShouldBeGreaterThanOrEqualTo(1);
         response.SegmentsCount.ShouldBeGreaterThanOrEqualTo(1);
         response.MessagesCount.ShouldBeGreaterThanOrEqualTo(1u);
-        response.ClientsCount.ShouldBeGreaterThanOrEqualTo(1);
+        if (!IggyServerFixture.IsServerNg)
+        {
+            // iggy-server-ng leaves the connected-client tally out of its stats reply.
+            response.ClientsCount.ShouldBeGreaterThanOrEqualTo(1);
+        }
+
         response.Hostname.ShouldNotBeNullOrEmpty();
         response.OsName.ShouldNotBeNullOrEmpty();
         response.OsVersion.ShouldNotBeNullOrEmpty();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs
index fa6a57796..0cb0f02a4 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/TopicsTests.cs
@@ -21,6 +21,7 @@ using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.Messages;
 using Apache.Iggy.Tests.Integrations.Fixtures;
+using Apache.Iggy.Tests.Integrations.Helpers;
 using Shouldly;
 using Partitioning = Apache.Iggy.Kinds.Partitioning;
 
@@ -40,8 +41,8 @@ public class TopicsTests
         var streamName = $"topic-create-{Guid.NewGuid():N}";
         await client.CreateStreamAsync(streamName);
 
-        var response = await client.CreateTopicAsync(
-            Identifier.String(streamName), "Test Topic", 2, CompressionAlgorithm.Gzip,
+        var response = await client.CreateTopicAsync(Identifier.String(streamName), "Test Topic", 2,
+            CompressionAlgorithm.Gzip,
             1, TimeSpan.FromMinutes(10), 2_000_000_000);
 
         response.ShouldNotBeNull();
@@ -68,8 +69,8 @@ public class TopicsTests
         await client.CreateStreamAsync(streamName);
         await client.CreateTopicAsync(Identifier.String(streamName), "Dup Topic", 1);
 
-        await Should.ThrowAsync<IggyInvalidStatusCodeException>(
-            client.CreateTopicAsync(Identifier.String(streamName), "Dup Topic", 1));
+        await Should.ThrowAsync<IggyInvalidStatusCodeException>(client.CreateTopicAsync(Identifier.String(streamName),
+            "Dup Topic", 1));
     }
 
     [Test]
@@ -197,13 +198,11 @@ public class TopicsTests
         var topicToUpdate = await client.CreateTopicAsync(Identifier.String(streamName), "topic-to-update", 1);
         topicToUpdate.ShouldNotBeNull();
 
-        await Should.NotThrowAsync(client.UpdateTopicAsync(
-            Identifier.String(streamName),
+        await Should.NotThrowAsync(client.UpdateTopicAsync(Identifier.String(streamName),
             Identifier.Numeric(topicToUpdate.Id), "Updated Topic",
             CompressionAlgorithm.Gzip, 3_000_000_000, TimeSpan.FromMinutes(10), 3));
 
-        var result = await client.GetTopicByIdAsync(
-            Identifier.String(streamName),
+        var result = await client.GetTopicByIdAsync(Identifier.String(streamName),
             Identifier.Numeric(topicToUpdate.Id));
         result.ShouldNotBeNull();
         result!.Name.ShouldBe("Updated Topic");
@@ -232,11 +231,13 @@ public class TopicsTests
         beforePurge.MessagesCount.ShouldBe(5u);
         beforePurge.Size.ShouldBeGreaterThan(0u);
 
-        await Should.NotThrowAsync(client.PurgeTopicAsync(
-            Identifier.String(streamName), Identifier.String("Purge Topic")));
+        await Should.NotThrowAsync(client.PurgeTopicAsync(Identifier.String(streamName),
+            Identifier.String("Purge Topic")));
 
-        var afterPurge = await client.GetTopicByIdAsync(Identifier.String(streamName),
-            Identifier.String("Purge Topic"));
+        // server-ng commits the purge by advancing a generation its reconciler acts on a tick later.
+        var afterPurge = await Eventually.ReadAsync(
+            () => client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.String("Purge Topic")),
+            topic => topic?.MessagesCount == 0, TimeSpan.FromSeconds(10));
         afterPurge.ShouldNotBeNull();
         afterPurge!.MessagesCount.ShouldBe(0u);
         afterPurge.Size.ShouldBe(0u);
@@ -253,8 +254,8 @@ public class TopicsTests
         var topicToDelete = await client.CreateTopicAsync(Identifier.String(streamName), "topic-to-delete", 1);
         topicToDelete.ShouldNotBeNull();
 
-        await Should.NotThrowAsync(client.DeleteTopicAsync(
-            Identifier.String(streamName), Identifier.Numeric(topicToDelete.Id)));
+        await Should.NotThrowAsync(client.DeleteTopicAsync(Identifier.String(streamName),
+            Identifier.Numeric(topicToDelete.Id)));
     }
 
     [Test]
@@ -266,8 +267,8 @@ public class TopicsTests
         var streamName = $"topic-delnone-{Guid.NewGuid():N}";
         await client.CreateStreamAsync(streamName);
 
-        await Should.ThrowAsync<IggyInvalidStatusCodeException>(client.DeleteTopicAsync(
-            Identifier.String(streamName), Identifier.String("nonexistent-topic")));
+        await Should.ThrowAsync<IggyInvalidStatusCodeException>(client.DeleteTopicAsync(Identifier.String(streamName),
+            Identifier.String("nonexistent-topic")));
     }
 
     [Test]
@@ -279,8 +280,8 @@ public class TopicsTests
         var streamName = $"topic-getnone-{Guid.NewGuid():N}";
         await client.CreateStreamAsync(streamName);
 
-        var topic = await client.GetTopicByIdAsync(
-            Identifier.String(streamName), Identifier.String("nonexistent-topic"));
+        var topic = await client.GetTopicByIdAsync(Identifier.String(streamName),
+            Identifier.String("nonexistent-topic"));
 
         topic.ShouldBeNull();
     }
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs
index c3820a307..3c86131b2 100644
--- a/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/UsersTests.cs
@@ -17,7 +17,6 @@
 
 using Apache.Iggy.Contracts;
 using Apache.Iggy.Contracts.Auth;
-using Apache.Iggy.Contracts.Http.Auth;
 using Apache.Iggy.Enums;
 using Apache.Iggy.Exceptions;
 using Apache.Iggy.Tests.Integrations.Fixtures;
@@ -54,8 +53,8 @@ public class UsersTests
         var username = $"dup-{Guid.NewGuid():N}"[..20];
         await client.CreateUserAsync(username, "test1", UserStatus.Active);
 
-        await Should.ThrowAsync<IggyInvalidStatusCodeException>(
-            client.CreateUserAsync(username, "test1", UserStatus.Active));
+        await Should.ThrowAsync<IggyInvalidStatusCodeException>(client.CreateUserAsync(username, "test1",
+            UserStatus.Active));
     }
 
     [Test]
@@ -169,10 +168,11 @@ public class UsersTests
         var username = $"chpw-{Guid.NewGuid():N}"[..20];
         await client.CreateUserAsync(username, "old_password", UserStatus.Active);
 
-        await Should.NotThrowAsync(client.ChangePasswordAsync(Identifier.String(username), "old_password", "new_password"));
+        await Should.NotThrowAsync(client.ChangePasswordAsync(Identifier.String(username), "old_password",
+            "new_password"));
 
         // Verify password was actually changed by logging in with the new credentials
-        var loginClient = await Fixture.CreateClient(protocol);
+        var loginClient = await Fixture.CreateClient(protocol, true);
         var loginResponse = await loginClient.LoginUserAsync(username, "new_password");
         loginResponse.ShouldNotBeNull();
         loginResponse.UserId.ShouldBeGreaterThan(0);
@@ -187,8 +187,8 @@ public class UsersTests
         var username = $"chpwf-{Guid.NewGuid():N}"[..20];
         await client.CreateUserAsync(username, "correct_password", UserStatus.Active);
 
-        await Should.ThrowAsync<IggyInvalidStatusCodeException>(
-            client.ChangePasswordAsync(Identifier.String(username), "wrong_password", "new_password"));
+        await Should.ThrowAsync<IggyInvalidStatusCodeException>(client.ChangePasswordAsync(Identifier.String(username),
+            "wrong_password", "new_password"));
     }
 
     [Test]
@@ -200,7 +200,7 @@ public class UsersTests
         var username = $"login-{Guid.NewGuid():N}"[..20];
         await client.CreateUserAsync(username, "login_password", UserStatus.Active);
 
-        var loginClient = await Fixture.CreateClient(protocol);
+        var loginClient = await Fixture.CreateClient(protocol, true);
         var response = await loginClient.LoginUserAsync(username, "login_password");
 
         response.ShouldNotBeNull();
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs
new file mode 100644
index 000000000..3d5acb74d
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrConsumerGroupTests.cs
@@ -0,0 +1,230 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Text;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Tests.Integrations.Attributes;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+
+namespace Apache.Iggy.Tests.Integrations.Vsr;
+
+/// <summary>
+///     Group polls under VSR: the server hands out an assignment, the client caches it and round-robins the
+///     assigned partitions itself, so a poll without an explicit partition id never reaches the broker as one.
+/// </summary>
+[RequiresServerNg]
+public class VsrConsumerGroupTests
+{
+    private const uint PartitionsCount = 3;
+    private const string TopicName = "vsr-group-topic";
+
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    public async Task GroupPoll_Should_Drain_EveryAssignedPartition()
+    {
+        var (client, streamName, groupName) = await CreateGroup();
+        await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName),
+            Identifier.String(groupName));
+
+        for (uint partitionId = 0; partitionId < PartitionsCount; partitionId++)
+        {
+            await SendAsync(client, streamName, partitionId);
+        }
+
+        // One poll per assigned partition drains it; the sole member owns them all, so the round-robin has to
+        // hand out every partition exactly once before it wraps.
+        var polled = await DrainGroupAsync(client, streamName, groupName, PartitionsCount);
+
+        polled.ShouldBe((int)PartitionsCount);
+    }
+
+    [Test]
+    public async Task GroupPoll_Without_Joining_Should_Throw_MemberNotFound()
+    {
+        var (client, streamName, groupName) = await CreateGroup();
+
+        var exception = await Should.ThrowAsync<IggyInvalidStatusCodeException>(
+            PollGroupAsync(client, streamName, groupName));
+
+        exception.StatusCode.ShouldBe(5006);
+    }
+
+    [Test]
+    public async Task GroupPoll_After_Leaving_Should_Throw_MemberNotFound()
+    {
+        var (client, streamName, groupName) = await CreateGroup();
+        await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName),
+            Identifier.String(groupName));
+        await PollGroupAsync(client, streamName, groupName);
+
+        await client.LeaveConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName),
+            Identifier.String(groupName));
+
+        var exception = await Should.ThrowAsync<IggyInvalidStatusCodeException>(
+            PollGroupAsync(client, streamName, groupName));
+
+        exception.StatusCode.ShouldBe(5006);
+    }
+
+    /// <summary>
+    ///     A partition count change widens the assignment, and the ping is where the client re-syncs it. The
+    ///     cached generation is asserted first: without it the test would pass on a client that re-synced on
+    ///     every poll and never needed the heartbeat.
+    /// </summary>
+    [Test]
+    public async Task Ping_Should_Refresh_TheGroupAssignment_After_PartitionsAreAdded()
+    {
+        var (client, streamName, groupName) = await CreateGroup();
+        await client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName),
+            Identifier.String(groupName));
+        await PollGroupAsync(client, streamName, groupName);
+
+        await client.CreatePartitionsAsync(Identifier.String(streamName), Identifier.String(TopicName), 1);
+        await SendAsync(client, streamName, PartitionsCount);
+
+        (await DrainGroupAsync(client, streamName, groupName, PartitionsCount + 1)).ShouldBe(0);
+
+        await client.PingAsync();
+
+        (await DrainGroupAsync(client, streamName, groupName, PartitionsCount + 1)).ShouldBe(1);
+    }
+
+    /// <summary>
+    ///     A member holding no partitions is still a member, so its poll has to come back empty instead of
+    ///     surfacing the not-a-member error the unassigned cursor otherwise looks like. One partition and two
+    ///     members guarantees exactly one of them is in that state.
+    /// </summary>
+    [Test]
+    public async Task GroupPoll_By_AMemberWithoutPartitions_Should_ReturnEmpty()
+    {
+        var (first, streamName, groupName) = await CreateGroup();
+        var topicName = $"vsr-single-partition-{Guid.NewGuid():N}";
+        await first.CreateTopicAsync(Identifier.String(streamName), topicName, 1);
+        await first.CreateConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName), groupName);
+
+        var second = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        await JoinAsync(first, streamName, topicName, groupName);
+        await JoinAsync(second, streamName, topicName, groupName);
+
+        await first.SendMessagesAsync(Identifier.String(streamName), Identifier.String(topicName),
+            Partitioning.PartitionId(0),
+            [new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes("vsr-single-partition-payload"))]);
+
+        var firstPoll = await PollGroupAsync(first, streamName, topicName, groupName);
+        var secondPoll = await PollGroupAsync(second, streamName, topicName, groupName);
+
+        // Whichever member drew the partition drains it, and the other one has nothing to poll.
+        (firstPoll.Messages.Count + secondPoll.Messages.Count).ShouldBe(1);
+        Math.Min(firstPoll.Messages.Count, secondPoll.Messages.Count).ShouldBe(0);
+    }
+
+    /// <summary>
+    ///     A second member rebalances the group, which leaves the first one round-robining partitions it no
+    ///     longer owns. The fence has to re-sync the assignment underneath the poll: a client that surfaced the
+    ///     ownership error instead would break every group app that did not special-case it.
+    /// </summary>
+    [Test]
+    public async Task GroupPoll_After_ASecondMemberJoins_Should_ResyncTheStaleAssignment()
+    {
+        var (first, streamName, groupName) = await CreateGroup();
+        await JoinAsync(first, streamName, TopicName, groupName);
+        await DrainGroupAsync(first, streamName, groupName, PartitionsCount);
+
+        var second = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        await JoinAsync(second, streamName, TopicName, groupName);
+
+        for (uint partitionId = 0; partitionId < PartitionsCount; partitionId++)
+        {
+            await SendAsync(first, streamName, partitionId);
+        }
+
+        // The first member still holds the pre-rebalance assignment, so its polls fence until they re-sync.
+        // Between them the two members have to see every partition; a lost one means a fence was swallowed.
+        var drained = await DrainGroupAsync(first, streamName, groupName, PartitionsCount);
+        drained += await DrainGroupAsync(second, streamName, groupName, PartitionsCount);
+
+        drained.ShouldBe((int)PartitionsCount);
+    }
+
+    private static Task JoinAsync(IIggyClient client, string streamName, string topicName, string groupName)
+    {
+        return client.JoinConsumerGroupAsync(Identifier.String(streamName), Identifier.String(topicName),
+            Identifier.String(groupName));
+    }
+
+    private async Task<(IIggyClient Client, string StreamName, string GroupName)> CreateGroup()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var streamName = $"vsr-group-{Guid.NewGuid():N}";
+        var groupName = $"vsr-group-name-{Guid.NewGuid():N}";
+
+        await client.CreateStreamAsync(streamName);
+        await client.CreateTopicAsync(Identifier.String(streamName), TopicName, PartitionsCount);
+        await client.CreateConsumerGroupAsync(Identifier.String(streamName), Identifier.String(TopicName), groupName);
+
+        return (client, streamName, groupName);
+    }
+
+    private static Task SendAsync(IIggyClient client, string streamName, uint partitionId)
+    {
+        return client.SendMessagesAsync(Identifier.String(streamName), Identifier.String(TopicName),
+            Partitioning.PartitionId((int)partitionId),
+            [new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes($"vsr-group-payload-{partitionId}"))]);
+    }
+
+    /// <summary>Polls once per assigned partition and returns how many messages came back in total.</summary>
+    private static async Task<int> DrainGroupAsync(IIggyClient client, string streamName, string groupName,
+        uint polls)
+    {
+        var drained = 0;
+        for (var poll = 0; poll < polls; poll++)
+        {
+            drained += (await PollGroupAsync(client, streamName, groupName)).Messages.Count;
+        }
+
+        return drained;
+    }
+
+    private static Task<PolledMessages> PollGroupAsync(IIggyClient client, string streamName, string groupName)
+    {
+        return PollGroupAsync(client, streamName, TopicName, groupName);
+    }
+
+    private static Task<PolledMessages> PollGroupAsync(IIggyClient client, string streamName, string topicName,
+        string groupName)
+    {
+        return client.PollMessagesAsync(new MessageFetchRequest
+        {
+            Count = 10,
+            AutoCommit = true,
+            Consumer = Consumer.Group(groupName),
+            PartitionId = null,
+            PollingStrategy = PollingStrategy.Next(),
+            StreamId = Identifier.String(streamName),
+            TopicId = Identifier.String(topicName)
+        });
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrHandshakeTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrHandshakeTests.cs
new file mode 100644
index 000000000..2eeac9256
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrHandshakeTests.cs
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Tests.Integrations.Attributes;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+
+namespace Apache.Iggy.Tests.Integrations.Vsr;
+
+/// <summary>
+///     The register handshake and the session it binds. Every other VSR suite depends on this one passing:
+///     without a bound session the server fences every replicated request.
+/// </summary>
+[RequiresServerNg]
+public class VsrHandshakeTests
+{
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    public async Task Login_Should_BindSession_And_ServeReplicatedRequests()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        var name = $"vsr-handshake-{Guid.NewGuid():N}";
+        var stream = await client.CreateStreamAsync(name);
+
+        stream.ShouldNotBeNull();
+        stream.Name.ShouldBe(name);
+    }
+
+    /// <summary>
+    ///     A re-login first logs out the bound session, then registers a fresh client identity. The metadata write
+    ///     after re-login proves the request counter belongs to that new binding instead of replaying a cached
+    ///     response from the old client table entry.
+    /// </summary>
+    [Test]
+    public async Task ReLogin_Should_AllowReplicatedRequests()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var name = $"vsr-relogin-{Guid.NewGuid():N}";
+        await client.CreateStreamAsync(name);
+
+        var response = await client.LoginUserAsync("iggy", "iggy");
+        response.ShouldNotBeNull();
+
+        var afterRelogin = $"vsr-relogin-after-{Guid.NewGuid():N}";
+        var stream = await client.CreateStreamAsync(afterRelogin);
+        stream.ShouldNotBeNull();
+        stream.Name.ShouldBe(afterRelogin);
+    }
+
+    [Test]
+    public async Task Logout_Should_UnbindTheSession_Until_TheClientRegistersAgain()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        await client.LogoutUserAsync();
+
+        await Should.ThrowAsync<Exception>(client.CreateStreamAsync($"vsr-logout-{Guid.NewGuid():N}"));
+
+        await client.LoginUserAsync("iggy", "iggy");
+
+        var name = $"vsr-logout-relogin-{Guid.NewGuid():N}";
+        (await client.CreateStreamAsync(name)).ShouldNotBeNull();
+    }
+
+    /// <summary>
+    ///     A rejected register is a consumed one on the server, so the failure has to reset the session and
+    ///     unwind the connection state. The retry with valid credentials is the assertion that matters: a client
+    ///     left in <c>Authenticating</c> with the failed register's session still bound would fence it.
+    ///     Wrong credentials fall through the server's PAT attempt and come back as a MalformedLogin eviction,
+    ///     not as the empty register reply, and an eviction is terminal for the connection: the retry has to
+    ///     reconnect first.
+    /// </summary>
+    [Test]
+    public async Task Login_WithInvalidCredentials_Should_ResetTheSession_And_AllowARetry()
+    {
+        var client = await Fixture.CreateUnauthenticatedClient(Protocol.Tcp);
+
+        var exception = await Should.ThrowAsync<IggyInvalidStatusCodeException>(
+            client.LoginUserAsync("iggy", "not-the-password"));
+
+        exception.StatusCode.ShouldBe(4);
+        exception.Message.ShouldContain("Malformed login body");
+
+        await client.ConnectAsync();
+        (await client.LoginUserAsync("iggy", "iggy")).ShouldNotBeNull();
+
+        var name = $"vsr-failed-login-{Guid.NewGuid():N}";
+        (await client.CreateStreamAsync(name)).ShouldNotBeNull();
+    }
+
+    [Test]
+    public async Task LoginWithPersonalAccessToken_Should_BindTheSession()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var token = await client.CreatePersonalAccessTokenAsync($"vsr-pat-{Guid.NewGuid():N}");
+        token.ShouldNotBeNull();
+
+        var patClient = await Fixture.CreateUnauthenticatedClient(Protocol.Tcp);
+        var response = await patClient.LoginWithPersonalAccessTokenAsync(token.Token);
+
+        response.ShouldNotBeNull();
+
+        var name = $"vsr-pat-stream-{Guid.NewGuid():N}";
+        (await patClient.CreateStreamAsync(name)).ShouldNotBeNull();
+    }
+
+    [Test]
+    public async Task Ping_Should_Succeed_Before_TheSessionIsBound()
+    {
+        var client = await Fixture.CreateUnauthenticatedClient(Protocol.Tcp);
+
+        // Non-replicated ops are sessionless, so an unbound client still pings.
+        await client.PingAsync();
+
+        await Should.ThrowAsync<Exception>(client.CreateStreamAsync($"vsr-unbound-{Guid.NewGuid():N}"));
+    }
+
+    [Test]
+    public async Task Ping_Should_Ride_NonReplicated_Without_GappingTheRequestCounter()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        // A ping that consumed a request id would gap the next metadata request, and the primary silently
+        // drops a gapped one - the create below would hang instead of failing loudly.
+        await client.PingAsync();
+        var first = await client.CreateStreamAsync($"vsr-ping-first-{Guid.NewGuid():N}");
+        await client.PingAsync();
+        var second = await client.CreateStreamAsync($"vsr-ping-second-{Guid.NewGuid():N}");
+
+        first.ShouldNotBeNull();
+        second.ShouldNotBeNull();
+    }
+
+    [Test]
+    public async Task Reads_Should_Ride_NonReplicated_Between_MetadataWrites()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        var name = $"vsr-reads-{Guid.NewGuid():N}";
+        await client.CreateStreamAsync(name);
+
+        IReadOnlyList<StreamResponse> streams = await client.GetStreamsAsync();
+        streams.ShouldContain(stream => stream.Name == name);
+
+        var second = $"vsr-reads-second-{Guid.NewGuid():N}";
+        (await client.CreateStreamAsync(second)).ShouldNotBeNull();
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs
new file mode 100644
index 000000000..9f2462f82
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMessagingTests.cs
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Text;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Enums;
+using Apache.Iggy.IggyClient;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Tests.Integrations.Attributes;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+
+namespace Apache.Iggy.Tests.Integrations.Vsr;
+
+/// <summary>
+///     The partition plane. Under VSR the broker never picks a partition, so the client resolves every
+///     partitioning kind to an explicit id before the request leaves - these tests assert the resolution
+///     lands where the Rust SDK's does.
+/// </summary>
+[RequiresServerNg]
+public class VsrMessagingTests
+{
+    private const uint PartitionsCount = 4;
+    private const string TopicName = "vsr-messages";
+
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    public async Task SendMessages_ToAnExplicitPartition_Should_PollBack_FromThatPartition()
+    {
+        var (client, streamName) = await CreateStreamAndTopic();
+
+        await SendAsync(client, streamName, Partitioning.PartitionId(2), 5);
+
+        var polled = await PollAsync(client, streamName, 2);
+        polled.Messages.Count.ShouldBe(5);
+        polled.PartitionId.ShouldBe(2);
+
+        (await PollAsync(client, streamName, 1)).Messages.ShouldBeEmpty();
+    }
+
+    /// <summary>
+    ///     Balanced partitioning is resolved client-side by round-robin over the topic's partition count, so
+    ///     a batch per partition ends up one message on each.
+    /// </summary>
+    [Test]
+    public async Task SendMessages_Balanced_Should_RoundRobin_AcrossEveryPartition()
+    {
+        var (client, streamName) = await CreateStreamAndTopic();
+
+        for (var i = 0; i < PartitionsCount; i++)
+        {
+            await SendAsync(client, streamName, Partitioning.None(), 1);
+        }
+
+        List<int> counts = await PollEveryPartitionAsync(client, streamName);
+
+        counts.Sum().ShouldBe((int)PartitionsCount);
+        counts.ShouldAllBe(count => count == 1);
+    }
+
+    /// <summary>
+    ///     The message key hashes to one partition, so every message under the same key lands together and
+    ///     two different keys are free to differ. Only the first is asserted: the hash is pinned by the unit
+    ///     tests against the Rust vectors, and asserting two keys differ would be a coin flip.
+    /// </summary>
+    [Test]
+    public async Task SendMessages_ByMessageKey_Should_LandOn_ASinglePartition()
+    {
+        var (client, streamName) = await CreateStreamAndTopic();
+        var key = Partitioning.EntityIdString($"key-{Guid.NewGuid():N}");
+
+        for (var i = 0; i < 6; i++)
+        {
+            await SendAsync(client, streamName, key, 1);
+        }
+
+        List<int> counts = await PollEveryPartitionAsync(client, streamName);
+
+        counts.Sum().ShouldBe(6);
+        counts.Count(count => count > 0).ShouldBe(1);
+    }
+
+    [Test]
+    public async Task ConsumerOffsets_Should_RoundTrip_ThroughTheResultSection()
+    {
+        var (client, streamName) = await CreateStreamAndTopic();
+        await SendAsync(client, streamName, Partitioning.PartitionId(0), 3);
+
+        var consumer = Consumer.New($"vsr-offset-{Guid.NewGuid():N}");
+        await client.StoreOffsetAsync(consumer, Identifier.String(streamName), Identifier.String(TopicName), 1, 0);
+
+        var stored = await client.GetOffsetAsync(consumer, Identifier.String(streamName),
+            Identifier.String(TopicName), 0);
+        stored.ShouldNotBeNull();
+        stored.StoredOffset.ShouldBe(1u);
+
+        await client.DeleteOffsetAsync(consumer, Identifier.String(streamName), Identifier.String(TopicName), 0);
+
+        var cleared = await client.GetOffsetAsync(consumer, Identifier.String(streamName),
+            Identifier.String(TopicName), 0);
+        cleared.ShouldSatisfyAllConditions(() => (cleared is null || cleared.StoredOffset == 0).ShouldBeTrue());
+    }
+
+    private async Task<(IIggyClient Client, string StreamName)> CreateStreamAndTopic()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+        var streamName = $"vsr-msg-{Guid.NewGuid():N}";
+
+        await client.CreateStreamAsync(streamName);
+        await client.CreateTopicAsync(Identifier.String(streamName), TopicName, PartitionsCount);
+
+        return (client, streamName);
+    }
+
+    private static Task SendAsync(IIggyClient client, string streamName, Partitioning partitioning, int count)
+    {
+        Message[] messages = Enumerable.Range(0, count)
+            .Select(index => new Message(Guid.NewGuid(), Encoding.UTF8.GetBytes($"vsr-payload-{index}")))
+            .ToArray();
+
+        return client.SendMessagesAsync(Identifier.String(streamName), Identifier.String(TopicName), partitioning,
+            messages);
+    }
+
+    private static Task<PolledMessages> PollAsync(IIggyClient client, string streamName, uint partitionId)
+    {
+        return client.PollMessagesAsync(new MessageFetchRequest
+        {
+            Count = 100,
+            AutoCommit = false,
+            Consumer = Consumer.New(1),
+            PartitionId = partitionId,
+            PollingStrategy = PollingStrategy.Offset(0),
+            StreamId = Identifier.String(streamName),
+            TopicId = Identifier.String(TopicName)
+        });
+    }
+
+    private static async Task<List<int>> PollEveryPartitionAsync(IIggyClient client, string streamName)
+    {
+        var counts = new List<int>();
+        for (uint partitionId = 0; partitionId < PartitionsCount; partitionId++)
+        {
+            counts.Add((await PollAsync(client, streamName, partitionId)).Messages.Count);
+        }
+
+        return counts;
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMetadataTests.cs b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMetadataTests.cs
new file mode 100644
index 000000000..6330d2d16
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK.Tests.Integration/Vsr/VsrMetadataTests.cs
@@ -0,0 +1,134 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Tests.Integrations.Attributes;
+using Apache.Iggy.Tests.Integrations.Fixtures;
+using Shouldly;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+
+namespace Apache.Iggy.Tests.Integrations.Vsr;
+
+/// <summary>
+///     Control-plane operations through the consensus path: every one of these consumes a request id and
+///     comes back with a committed result section the decoder has to strip before the typed mapper runs.
+/// </summary>
+[RequiresServerNg]
+public class VsrMetadataTests
+{
+    [ClassDataSource<IggyServerFixture>(Shared = SharedType.PerAssembly)]
+    public required IggyServerFixture Fixture { get; init; }
+
+    [Test]
+    public async Task StreamLifecycle_Should_CommitThroughConsensus()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        var name = $"vsr-meta-stream-{Guid.NewGuid():N}";
+        var created = await client.CreateStreamAsync(name);
+        created.ShouldNotBeNull();
+
+        var fetched = await client.GetStreamByIdAsync(Identifier.Numeric(created.Id));
+        fetched.ShouldNotBeNull();
+        fetched.Name.ShouldBe(name);
+
+        await client.DeleteStreamAsync(Identifier.Numeric(created.Id));
+        (await client.GetStreamsAsync()).ShouldNotContain(stream => stream.Name == name);
+    }
+
+    [Test]
+    public async Task TopicLifecycle_Should_CommitThroughConsensus()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        var streamName = $"vsr-meta-topic-{Guid.NewGuid():N}";
+        await client.CreateStreamAsync(streamName);
+
+        var topic = await client.CreateTopicAsync(Identifier.String(streamName), "vsr-topic", 3);
+        topic.ShouldNotBeNull();
+        topic.PartitionsCount.ShouldBe(3u);
+
+        var fetched = await client.GetTopicByIdAsync(Identifier.String(streamName), Identifier.Numeric(topic.Id));
+        fetched.ShouldNotBeNull();
+        fetched.PartitionsCount.ShouldBe(3u);
+
+        await client.DeleteTopicAsync(Identifier.String(streamName), Identifier.Numeric(topic.Id));
+        (await client.GetTopicsAsync(Identifier.String(streamName))).ShouldBeEmpty();
+    }
+
+    /// <summary>
+    ///     A committed rejection rides the result section with status 0 in the header, so the decoder has to
+    ///     read the first result entry to see it. A silent success here would mean the section was skipped.
+    /// </summary>
+    [Test]
+    public async Task DuplicateStream_Should_Surface_TheCommittedRejection()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        var name = $"vsr-meta-dup-{Guid.NewGuid():N}";
+        await client.CreateStreamAsync(name);
+
+        await Should.ThrowAsync<IggyInvalidStatusCodeException>(client.CreateStreamAsync(name));
+    }
+
+    [Test]
+    public async Task UserLifecycle_Should_CommitThroughConsensus()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        var name = $"vsr-user-{Guid.NewGuid():N}";
+        var user = await client.CreateUserAsync(name, "secret-password", UserStatus.Active);
+        user.ShouldNotBeNull();
+
+        var fetched = await client.GetUserAsync(Identifier.Numeric(user.Id));
+        fetched.ShouldNotBeNull();
+        fetched.Username.ShouldBe(name);
+
+        await client.DeleteUserAsync(Identifier.Numeric(user.Id));
+        (await client.GetUserAsync(Identifier.Numeric(user.Id))).ShouldBeNull();
+    }
+
+    /// <summary>
+    ///     Partition ops read the request counter without advancing it. Interleaving them with metadata
+    ///     writes catches the asymmetry: a partition op that consumed an id would gap the next metadata one
+    ///     and the primary would silently drop it.
+    /// </summary>
+    [Test]
+    public async Task MetadataWrites_Should_KeepCommitting_Around_PartitionOps()
+    {
+        var client = await Fixture.CreateAuthenticatedClient(Protocol.Tcp);
+
+        var streamName = $"vsr-meta-mixed-{Guid.NewGuid():N}";
+        await client.CreateStreamAsync(streamName);
+        await client.CreateTopicAsync(Identifier.String(streamName), "vsr-mixed-topic", 1);
+
+        await client.SendMessagesAsync(Identifier.String(streamName), Identifier.String("vsr-mixed-topic"),
+            Partitioning.PartitionId(0),
+            [new Message(Guid.NewGuid(), "vsr-mixed"u8.ToArray())]);
+        await client.StoreOffsetAsync(Consumer.New(1), Identifier.String(streamName),
+            Identifier.String("vsr-mixed-topic"), 0, 0);
+
+        var second = $"vsr-meta-mixed-second-{Guid.NewGuid():N}";
+        var created = await client.CreateStreamAsync(second);
+
+        created.ShouldNotBeNull();
+        created.Name.ShouldBe(second);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs b/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
index b1b434f56..3c88446ec 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
@@ -36,4 +36,19 @@ public class AutoLoginSettings
     ///     Specifies the password for auto-login authentication
     /// </summary>
     public string Password { get; set; } = string.Empty;
+
+    /// <summary>
+    ///     Settings for a builder-owned client that signs in with the given credentials. The credentials must
+    ///     reach the client and not only the explicit login the wrapper performs at startup: a reconnect or a
+    ///     leader redirect drops the session, and without them the client comes back unauthenticated.
+    /// </summary>
+    internal static AutoLoginSettings For(string username, string password)
+    {
+        return new AutoLoginSettings
+        {
+            Enabled = !string.IsNullOrEmpty(username),
+            Username = username,
+            Password = password
+        };
+    }
 }
diff --git a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
index b8c555a81..5478a1032 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
+++ b/foreign/csharp/Iggy_SDK/Configuration/IggyClientConfigurator.cs
@@ -37,6 +37,18 @@ public sealed class IggyClientConfigurator
     /// </summary>
     public required Protocol Protocol { get; set; }
 
+    /// <summary>
+    ///     The wire framing to use. Default is <see cref="Enums.WireProtocol.Classic" />.
+    ///     <see cref="Enums.WireProtocol.Vsr" /> requires <see cref="Enums.Protocol.Tcp" />.
+    /// </summary>
+    public WireProtocol WireProtocol { get; set; } = WireProtocol.Classic;
+
+    /// <summary>
+    ///     The largest response frame accepted under <see cref="Enums.WireProtocol.Vsr" />, in bytes.
+    ///     Default is 64 MiB, minimum is the 256-byte header.
+    /// </summary>
+    public int MaxResponseFrameSize { get; set; } = 64 * 1024 * 1024;
+
     /// <summary>
     ///     The size of the receive buffer in bytes. Default is 4096.
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
index e75b60ae3..9b4bbfaeb 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.Rented.cs
@@ -198,13 +198,7 @@ public partial class IggyConsumer
             LogFailedToDecryptMessage(ex, ex.Offset, ex.PartitionId);
             throw;
         }
-        catch (MalformedResponseException)
-        {
-            // Non-transient poison: rethrow so the generic catch below does not swallow it and re-poll forever.
-            // Base InvalidResponseException (server error status, possibly transient) falls through to retry.
-            throw;
-        }
-        catch (Exception ex)
+        catch (Exception ex) when (ex is not (MalformedResponseException or VsrRequestOutcomeUnknownException))
         {
             LogFailedToPollMessages(ex);
             _consumerErrorEvents.Publish(new ConsumerErrorEventArgs(ex, "Failed to poll messages"));
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
index c1201d027..b5317cfde 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumer.cs
@@ -154,7 +154,7 @@ public partial class IggyConsumer : IAsyncDisposable
 
             await _client.ConnectAsync(ct);
 
-            if (_config.CreateIggyClient)
+            if (!string.IsNullOrEmpty(_config.Login) && !_config.CreateIggyClient)
             {
                 await _client.LoginUserAsync(_config.Login, _config.Password, ct);
             }
@@ -441,13 +441,7 @@ public partial class IggyConsumer : IAsyncDisposable
             LogFailedToDecryptMessage(ex, ex.Offset, ex.PartitionId);
             throw;
         }
-        catch (MalformedResponseException)
-        {
-            // Non-transient poison: rethrow so the generic catch below does not swallow it and re-poll forever.
-            // Base InvalidResponseException (server error status, possibly transient) falls through to retry.
-            throw;
-        }
-        catch (Exception ex)
+        catch (Exception ex) when (ex is not (MalformedResponseException or VsrRequestOutcomeUnknownException))
         {
             LogFailedToPollMessages(ex);
             _consumerErrorEvents.Publish(new ConsumerErrorEventArgs(ex, "Failed to poll messages"));
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
index 937ca10c5..d52e85a8e 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilder.cs
@@ -31,7 +31,7 @@ namespace Apache.Iggy.Consumers;
 /// </summary>
 public class IggyConsumerBuilder
 {
-    private IMessageEncryptor? _encryptor;
+    private protected IMessageEncryptor? _encryptor;
 
     internal Func<ConsumerErrorEventArgs, Task>? OnPollingError { get; set; }
     internal IggyConsumerConfig Config { get; set; } = new();
@@ -39,7 +39,7 @@ public class IggyConsumerBuilder
 
     /// <summary>
     ///     Creates a new consumer builder that will create its own Iggy client.
-    ///     You must configure connection settings using <see cref="WithConnection" />.
+    ///     You must configure connection settings using <c>WithConnection</c>.
     /// </summary>
     /// <param name="streamId">The stream identifier to consume from</param>
     /// <param name="topicId">The topic identifier to consume from</param>
@@ -108,6 +108,18 @@ public class IggyConsumerBuilder
         return this;
     }
 
+    /// <summary>
+    ///     Selects the wire framing the consumer's client speaks. Defaults to <see cref="WireProtocol.Classic" />.
+    /// </summary>
+    /// <param name="wireProtocol">The wire framing to use. VSR requires <see cref="Protocol.Tcp" />.</param>
+    /// <returns>The current instance of <see cref="IggyConsumerBuilder" /> to allow method chaining.</returns>
+    public IggyConsumerBuilder WithWireProtocol(WireProtocol wireProtocol)
+    {
+        Config.WireProtocol = wireProtocol;
+
+        return this;
+    }
+
     /// <summary>
     ///     Configures message encryption on the client this builder creates. Encryption is a client-level concern:
     ///     the encryptor decrypts on poll (and encrypts on send) for the whole connection. Only valid when the
@@ -241,10 +253,12 @@ public class IggyConsumerBuilder
             IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator
             {
                 Protocol = Config.Protocol,
+                WireProtocol = Config.WireProtocol,
                 BaseAddress = Config.Address,
                 ReceiveBufferSize = Config.ReceiveBufferSize,
                 SendBufferSize = Config.SendBufferSize,
                 ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
+                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
                 LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
                 MessageEncryptor = _encryptor
             });
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs
index 0ccf68cc1..f7ba89992 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerBuilderOfT.cs
@@ -19,7 +19,6 @@ using Apache.Iggy.Configuration;
 using Apache.Iggy.Factory;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.Kinds;
-using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
 
 namespace Apache.Iggy.Consumers;
@@ -93,9 +92,14 @@ public class IggyConsumerBuilder<T> : IggyConsumerBuilder
             IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator
             {
                 Protocol = Config.Protocol,
+                WireProtocol = Config.WireProtocol,
                 BaseAddress = Config.Address,
                 ReceiveBufferSize = Config.ReceiveBufferSize,
-                SendBufferSize = Config.SendBufferSize
+                SendBufferSize = Config.SendBufferSize,
+                ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
+                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
+                LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
+                MessageEncryptor = _encryptor
             });
         }
 
@@ -133,8 +137,7 @@ public class IggyConsumerBuilder<T> : IggyConsumerBuilder
         }
         else
         {
-            throw new InvalidOperationException(
-                $"Config must be of type IggyConsumerConfig<{typeof(T).Name}>.");
+            throw new InvalidOperationException($"Config must be of type IggyConsumerConfig<{typeof(T).Name}>.");
         }
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
index 1418282d3..bae681c24 100644
--- a/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
+++ b/foreign/csharp/Iggy_SDK/Consumers/IggyConsumerConfig.cs
@@ -54,6 +54,13 @@ public class IggyConsumerConfig
     /// </summary>
     public Protocol Protocol { get; set; }
 
+    /// <summary>
+    ///     The wire framing to use. Defaults to <see cref="Enums.WireProtocol.Classic" />;
+    ///     <see cref="Enums.WireProtocol.Vsr" /> requires <see cref="Enums.Protocol.Tcp" />.
+    ///     Only used when <see cref="CreateIggyClient" /> is true.
+    /// </summary>
+    public WireProtocol WireProtocol { get; set; } = WireProtocol.Classic;
+
     /// <summary>
     ///     The server address to connect to
     /// </summary>
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs b/foreign/csharp/Iggy_SDK/Enums/WireProtocol.cs
similarity index 67%
copy from foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
copy to foreign/csharp/Iggy_SDK/Enums/WireProtocol.cs
index 6ba6e14d3..b04f1d680 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
+++ b/foreign/csharp/Iggy_SDK/Enums/WireProtocol.cs
@@ -15,20 +15,21 @@
 // specific language governing permissions and limitations
 // under the License.
 
-namespace Apache.Iggy.Exceptions;
+namespace Apache.Iggy.Enums;
 
 /// <summary>
-///     Exception thrown when the status code returned by the server is not valid.
+///     The wire framing used on top of the transport. Independent of <see cref="Protocol" />, which selects
+///     the transport itself.
 /// </summary>
-public sealed class IggyInvalidStatusCodeException : Exception
+public enum WireProtocol
 {
     /// <summary>
-    ///     Status code returned by the server.
+    ///     Classic framing: <c>[length u32][command code u32][body]</c>.
     /// </summary>
-    public int StatusCode { get; init; }
+    Classic,
 
-    internal IggyInvalidStatusCodeException(int statusCode, string message) : base(message)
-    {
-        StatusCode = statusCode;
-    }
+    /// <summary>
+    ///     Viewstamped Replication framing: a 256-byte consensus header followed by the body. TCP only.
+    /// </summary>
+    Vsr
 }
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs b/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
index 6ba6e14d3..9d73640d5 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
+++ b/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
@@ -25,10 +25,18 @@ public sealed class IggyInvalidStatusCodeException : Exception
     /// <summary>
     ///     Status code returned by the server.
     /// </summary>
-    public int StatusCode { get; init; }
+    public int StatusCode { get; }
 
-    internal IggyInvalidStatusCodeException(int statusCode, string message) : base(message)
+    /// <summary>
+    ///     Whether the status code was reported by the server rather than raised by the client. The two share one
+    ///     code space, and only a server verdict may drive retry or failover: a locally raised code says nothing
+    ///     about what the cluster did with the request.
+    /// </summary>
+    public bool FromServer { get; }
+
+    internal IggyInvalidStatusCodeException(int statusCode, string message, bool fromServer = false) : base(message)
     {
         StatusCode = statusCode;
+        FromServer = fromServer;
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs
new file mode 100644
index 000000000..f79334b28
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrRequestOutcomeUnknownException.cs
@@ -0,0 +1,33 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+namespace Apache.Iggy.Exceptions;
+
+/// <summary>
+///     The request produced no server verdict after transmission began, so the server may already have committed
+///     it. Replaying it on a fresh consensus session would bypass server-side deduplication, so the SDK refuses to
+///     retry and surfaces this instead: the caller decides whether re-issuing the operation is safe.
+/// </summary>
+/// <remarks>
+///     Deliberately derived from <see cref="Exception" /> rather than <see cref="IOException" /> or
+///     <see cref="OperationCanceledException" />, whichever ended the request. Both of those are routinely caught
+///     and either retried or swallowed, which are the two responses this type exists to prevent. The triggering
+///     exception is preserved as <see cref="Exception.InnerException" />.
+/// </remarks>
+public sealed class VsrRequestOutcomeUnknownException(Exception innerException)
+    : Exception("The VSR request outcome is unknown because no server verdict arrived after transmission began.",
+        innerException);
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs b/foreign/csharp/Iggy_SDK/Exceptions/VsrSessionEvictedException.cs
similarity index 52%
copy from foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
copy to foreign/csharp/Iggy_SDK/Exceptions/VsrSessionEvictedException.cs
index 6ba6e14d3..824356618 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
+++ b/foreign/csharp/Iggy_SDK/Exceptions/VsrSessionEvictedException.cs
@@ -18,17 +18,17 @@
 namespace Apache.Iggy.Exceptions;
 
 /// <summary>
-///     Exception thrown when the status code returned by the server is not valid.
+///     An eviction frame arrived where a reply was expected. Internal: the transport decides whether the caller
+///     sees <see cref="Verdict" /> or an unknown outcome, depending on whether the outstanding request could
+///     still have committed.
 /// </summary>
-public sealed class IggyInvalidStatusCodeException : Exception
+/// <remarks>
+///     The server emits evictions off its own heartbeat timer rather than as an answer, so the frame carries no
+///     correlation with the request it interrupts and that request's real reply is never read.
+/// </remarks>
+internal sealed class VsrSessionEvictedException(Exception verdict)
+    : Exception("The consensus session was evicted by the server.", verdict)
 {
-    /// <summary>
-    ///     Status code returned by the server.
-    /// </summary>
-    public int StatusCode { get; init; }
-
-    internal IggyInvalidStatusCodeException(int statusCode, string message) : base(message)
-    {
-        StatusCode = statusCode;
-    }
+    /// <summary>The error the eviction reason maps to, for the requests it can safely be reported as.</summary>
+    internal Exception Verdict { get; } = verdict;
 }
diff --git a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
index 430046490..7562dbabc 100644
--- a/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
+++ b/foreign/csharp/Iggy_SDK/Factory/IggyClientFactory.cs
@@ -20,6 +20,7 @@ using Apache.Iggy.Configuration;
 using Apache.Iggy.Enums;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.IggyClient.Implementations;
+using Apache.Iggy.Vsr;
 
 namespace Apache.Iggy.Factory;
 
@@ -44,8 +45,17 @@ public static class IggyClientFactory
     ///     Thrown when the specified protocol in <paramref name="options" /> is not
     ///     supported.
     /// </exception>
+    /// <exception cref="ArgumentException">
+    ///     Thrown when <see cref="IggyClientConfigurator.WireProtocol" /> is
+    ///     <see cref="WireProtocol.Vsr" /> and the transport is not <see cref="Protocol.Tcp" />.
+    /// </exception>
+    /// <exception cref="ArgumentOutOfRangeException">
+    ///     Thrown when <see cref="IggyClientConfigurator.MaxResponseFrameSize" /> is below the 256-byte header.
+    /// </exception>
     public static IIggyClient CreateClient(IggyClientConfigurator options)
     {
+        Validate(options);
+
         return options.Protocol switch
         {
             Protocol.Http => CreateIggyHttpClient(options),
@@ -54,6 +64,27 @@ public static class IggyClientFactory
         };
     }
 
+    private static void Validate(IggyClientConfigurator options)
+    {
+        if (options.WireProtocol != WireProtocol.Vsr)
+        {
+            return;
+        }
+
+        if (options.MaxResponseFrameSize < VsrHeader.HEADER_SIZE)
+        {
+            throw new ArgumentOutOfRangeException(nameof(options), options.MaxResponseFrameSize,
+                $"MaxResponseFrameSize must be at least {VsrHeader.HEADER_SIZE} bytes.");
+        }
+
+        if (options.Protocol != Protocol.Tcp)
+        {
+            throw new ArgumentException(
+                $"WireProtocol.Vsr requires Protocol.Tcp, but {options.Protocol} was configured.",
+                nameof(options));
+        }
+    }
+
     private static IIggyClient CreateIggyTcpClient(IggyClientConfigurator options)
     {
         return new TcpMessageStream(options, options.LoggerFactory);
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs b/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs
index 32acfcec8..aab60f1c6 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/IIggySystem.cs
@@ -86,7 +86,10 @@ public interface IIggySystem
     ///     Sends a ping request to the server to verify connectivity.
     /// </summary>
     /// <remarks>
-    ///     This is a simple health check operation that can be used to verify the connection is active.
+    ///     This is a simple health check operation that can be used to verify the connection is active. On the
+    ///     VSR wire protocol it also re-syncs the assignment of every consumer group this client has joined, so
+    ///     it costs one extra round trip per joined group. The SDK never calls it on its own: an application
+    ///     that wants assignments refreshed has to ping on its own cadence.
     /// </remarks>
     /// <param name="token">The cancellation token to cancel the operation.</param>
     /// <returns>A task representing the asynchronous operation.</returns>
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
index 4aba83255..3eee5a905 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/HttpMessageStream.cs
@@ -988,7 +988,7 @@ public class HttpMessageStream : IIggyClient
         {
             var err = await response.Content.ReadAsStringAsync();
             var errorModel = JsonSerializer.Deserialize<ErrorResponse>(err);
-            throw new IggyInvalidStatusCodeException(errorModel?.Id ?? -1, err);
+            throw new IggyInvalidStatusCodeException(errorModel?.Id ?? -1, err, true);
         }
 
         if (response.StatusCode == HttpStatusCode.InternalServerError)
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
new file mode 100644
index 000000000..72ce548f7
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.Vsr.cs
@@ -0,0 +1,1062 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers;
+using System.Buffers.Binary;
+using System.IO.Hashing;
+using System.Runtime.ExceptionServices;
+using Apache.Iggy.ConnectionStream;
+using Apache.Iggy.Contracts;
+using Apache.Iggy.Contracts.Auth;
+using Apache.Iggy.Contracts.Tcp;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Kinds;
+using Apache.Iggy.Messages;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+using Microsoft.Extensions.Logging;
+using Partitioning = Apache.Iggy.Kinds.Partitioning;
+
+namespace Apache.Iggy.IggyClient.Implementations;
+
+/// <summary>
+///     The consensus (VSR) half of the TCP client: the framed request path, the leader redirection, the
+///     register handshake, and the client-side partitioning and consumer-group assignment the broker does not
+///     resolve server-side. The classic path lives in <see cref="TcpMessageStream" />.
+/// </summary>
+public sealed partial class TcpMessageStream
+{
+    /// <summary>
+    ///     Upper bound for a whole VSR request: the transient replays and the leader failovers share it, and so
+    ///     do the reply header and body reads. The connection is lockstep, so an unanswered read would hold the
+    ///     sending semaphore forever and wedge every later request.
+    /// </summary>
+    private const int VsrRequestTimeoutMs = 30_000;
+
+    /// <summary>Backoff between replays of a transiently refused request.</summary>
+    private const int VsrTransientRetryIntervalMs = 50;
+
+    /// <summary>
+    ///     Largest body still sent as one contiguous frame with its header. Beyond this the copy outweighs the
+    ///     syscall and the extra segment it saves, so header and body go out as two writes.
+    /// </summary>
+    private const int VsrContiguousFrameLimit = 4 * 1024;
+
+    /// <summary>
+    ///     How long a <see cref="VsrError.TRANSIENT_NOT_ACCEPTED" /> request replays on the same connection
+    ///     before the leader roster is re-checked. A node that stopped being primary refuses forever, so
+    ///     replaying alone never recovers.
+    /// </summary>
+    private const int VsrTransientFailoverCheckMs = 2_000;
+
+    /// <summary>
+    ///     How long a transiently leaderless roster is polled before the connection proceeds on the current
+    ///     node anyway. A restarted node cedes the primaryship its stale view assigns it, and the peers need
+    ///     about one heartbeat timeout to elect.
+    /// </summary>
+    private const int VsrLeaderlessWaitMs = 5_000;
+
+    private const int VsrLeaderlessPollMs = 250;
+
+    /// <summary>
+    ///     Cap on consecutive leader redirects, so a flapping roster cannot spin the connect loop or the
+    ///     transient failover path. The budget is client-wide and resets on a roster check that finds the
+    ///     current node is the leader, and on every request that completes, so a client that outlives more
+    ///     leader changes than the cap does not latch onto a follower for good.
+    /// </summary>
+    private const int VsrMaxLeaderRedirects = 3;
+
+    /// <summary>
+    ///     Attempts a consumer-group poll gets before it gives up and reports an empty poll: one re-sync after
+    ///     the coordinator fences a stale assignment, then one retry.
+    /// </summary>
+    private const int VsrGroupPollMaxAttempts = 2;
+
+    /// <summary>
+    ///     Partition id a fenced group poll echoes instead of a typed error, matching
+    ///     <c>RESYNC_REQUIRED_PARTITION_SENTINEL</c> (<c>u32::MAX</c>). The reply header carries no status for an
+    ///     empty poll, so the sentinel is the only channel the coordinator has to ask for a re-sync.
+    /// </summary>
+    private const int VsrResyncRequiredPartitionSentinel = -1;
+
+    /// <summary>
+    ///     Shared empty poll result. An idle consumer loop returns one on every iteration, and the instance owns
+    ///     no rented buffer - <see cref="EmptyMemoryOwner" /> disposes to nothing - so it is safe to hand out
+    ///     repeatedly even after a caller disposes it.
+    /// </summary>
+    /// <summary>
+    ///     Non-replicated codes this build knows to leave no server-side state behind, so re-sending one after a
+    ///     lost connection is indistinguishable from sending it once. Flushing an unsaved buffer is included: it
+    ///     is idempotent by construction, a second flush writes nothing new.
+    /// </summary>
+    private static readonly HashSet<int> NonReplicatedReadCodes =
+    [
+        CommandCodes.PING_CODE,
+        CommandCodes.GET_STATS_CODE,
+        CommandCodes.GET_SNAPSHOT_CODE,
+        CommandCodes.GET_CLUSTER_METADATA_CODE,
+        CommandCodes.GET_ME_CODE,
+        CommandCodes.GET_CLIENT_CODE,
+        CommandCodes.GET_CLIENTS_CODE,
+        CommandCodes.GET_USER_CODE,
+        CommandCodes.GET_USERS_CODE,
+        CommandCodes.GET_PERSONAL_ACCESS_TOKENS_CODE,
+        CommandCodes.FLUSH_UNSAVED_BUFFER_CODE,
+        CommandCodes.GET_CONSUMER_OFFSET_CODE,
+        CommandCodes.GET_STREAM_CODE,
+        CommandCodes.GET_STREAMS_CODE,
+        CommandCodes.GET_TOPIC_CODE,
+        CommandCodes.GET_TOPICS_CODE,
+        CommandCodes.GET_CONSUMER_GROUP_CODE,
+        CommandCodes.GET_CONSUMER_GROUPS_CODE,
+        CommandCodes.SYNC_CONSUMER_GROUP_CODE
+    ];
+
+    private static readonly PolledMessagesRental EmptyPolledMessages = new(EmptyMemoryOwner.Instance)
+    {
+        PartitionId = 0,
+        CurrentOffset = 0,
+        Messages = []
+    };
+
+    private readonly ConsensusSession _consensusSession = new();
+    private readonly ConsumerGroupClientState _groupState = new();
+    private readonly bool _isVsr;
+    private readonly byte[] _vsrReplyHeaderBuffer = new byte[VsrHeader.HEADER_SIZE];
+
+    // The redirect budget is refunded by a completed request, and the roster check a redirect runs is itself a
+    // request. Without this the refund lands between the check and the increment that reads the budget, and the
+    // counter never leaves zero. Nonzero for the duration of a roster read, so that refund is skipped.
+    private int _leaderProbeDepth;
+
+    /// <summary>
+    ///     Runs the consensus register handshake and binds the session it commits. Everything before the bind
+    ///     is a consumed register on the server, so any failure resets the session: the next attempt must
+    ///     re-register under a fresh client id rather than send requests the primary would fence.
+    /// </summary>
+    /// <remarks>
+    ///     A bound connection must commit logout before it can register again. The server treats a register on
+    ///     an already-bound transport as an idempotent replay of the existing binding, so re-arming only the
+    ///     local session would pair a fresh client id and request counter with the old server session.
+    /// </remarks>
+    private async Task<AuthResponse?> LoginRegisterAsync(int code, byte[] message, CancellationToken token)
+    {
+        for (var redirects = 0; ; redirects++)
+        {
+            if (_consensusSession.IsBound)
+            {
+                await LogoutUserAsync(token);
+            }
+            else if (_state == ConnectionState.Authenticated)
+            {
+                SetConnectionStateAsync(ConnectionState.Connected);
+            }
+
+            var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length];
+            TcpMessageStreamHelpers.CreatePayload(payload, message, code);
+
+            SetConnectionStateAsync(ConnectionState.Authenticating);
+
+            LoginRegisterResponse response;
+            try
+            {
+                Interlocked.Exchange(ref _skipAutoLoginOnce, 1);
+                using IMemoryOwner<byte> responseBuffer = await SendWithResponseAsync(payload, token);
+
+                response = LoginRegister.Deserialize(responseBuffer.Memory.Span);
+                _consensusSession.Bind(response.Session);
+            }
+            catch
+            {
+                await ResetConsensusSessionAsync();
+                if (_state == ConnectionState.Authenticating)
+                {
+                    SetConnectionStateAsync(ConnectionState.Connected);
+                }
+
+                throw;
+            }
+            finally
+            {
+                Interlocked.Exchange(ref _skipAutoLoginOnce, 0);
+            }
+
+            _logger.LogInformation(
+                "Authenticated against the server, version {ServerVersion}, protocol version {ServerProtocolVersion}",
+                response.ServerVersion, response.ServerProtocolVersion);
+            SetConnectionStateAsync(ConnectionState.Authenticated);
+
+            var authResponse = new AuthResponse((int)response.UserId, null);
+            if (IsConnecting)
+            {
+                return authResponse;
+            }
+
+            if (redirects >= VsrMaxLeaderRedirects)
+            {
+                _logger.LogWarning("Maximum leader redirections reached while registering, staying on {Address}",
+                    _currentAddress);
+
+                return authResponse;
+            }
+
+            if (!await RedirectAsync(token))
+            {
+                return authResponse;
+            }
+
+            try
+            {
+                Interlocked.Exchange(ref _skipAutoLoginOnce, 1);
+                await ConnectAsync(token);
+            }
+            finally
+            {
+                Interlocked.Exchange(ref _skipAutoLoginOnce, 0);
+            }
+        }
+    }
+
+    /// <summary>
+    ///     Whether the partitioning has to be resolved to an explicit partition id before the request is framed.
+    ///     Only VSR needs this: the classic server still round-robins and hashes server-side.
+    /// </summary>
+    private bool NeedsClientSidePartitioning(Partitioning partitioning)
+    {
+        return _isVsr && partitioning.Kind != Enums.Partitioning.PartitionId;
+    }
+
+    private async Task SendMessagesResolvedAsync(Identifier streamId, Identifier topicId, Partitioning partitioning,
+        IList<Message> messages, CancellationToken token)
+    {
+        var resolved = await ResolvePartitioningAsync(streamId, topicId, partitioning, token);
+
+        await SendMessagesCoreAsync(streamId, topicId, resolved, AsSpan(messages), token);
+    }
+
+    /// <summary>
+    ///     Resolves balanced and message-key partitioning to an explicit partition id, mirroring
+    ///     <c>core/common/src/traits/binary_impls/messages.rs</c>. The VSR broker never picks a partition, so
+    ///     sending either kind on the wire would fail to route.
+    /// </summary>
+    private async ValueTask<Partitioning> ResolvePartitioningAsync(Identifier streamId, Identifier topicId,
+        Partitioning partitioning, CancellationToken token)
+    {
+        var partitionCount = await TopicPartitionCountAsync(streamId, topicId, token);
+        if (partitionCount == 0)
+        {
+            throw VsrError.Exception(VsrError.TOPIC_ID_NOT_FOUND,
+                $"Topic {topicId} in stream {streamId} has no partitions to resolve the message to.");
+        }
+
+        var partition = partitioning.Kind switch
+        {
+            Enums.Partitioning.Balanced => _groupState.NextBalancedPartition(
+                new TopicKey(streamId, topicId), partitionCount),
+            Enums.Partitioning.MessageKey => XxHash32.HashToUInt32(partitioning.Value) % partitionCount,
+            _ => throw VsrError.Exception(VsrError.FEATURE_UNAVAILABLE,
+                $"Partitioning kind {partitioning.Kind} cannot be resolved to a partition id.")
+        };
+
+        return Partitioning.PartitionId((int)partition);
+    }
+
+    private async ValueTask<uint> TopicPartitionCountAsync(Identifier streamId, Identifier topicId,
+        CancellationToken token)
+    {
+        var key = new TopicKey(streamId, topicId);
+        if (_groupState.PartitionCount(key) is { } cached)
+        {
+            return cached;
+        }
+
+        var topic = await GetTopicByIdAsync(streamId, topicId, token);
+        if (topic is null)
+        {
+            throw VsrError.Exception(VsrError.TOPIC_ID_NOT_FOUND,
+                $"Topic {topicId} was not found in stream {streamId}.");
+        }
+
+        _groupState.SetPartitionCount(key, topic.PartitionsCount);
+
+        return topic.PartitionsCount;
+    }
+
+    /// <summary>
+    ///     Polls one of the group member's assigned partitions, round-robin. A fence rejection - either the typed
+    ///     error or the sentinel partition id an empty poll carries - re-syncs the assignment and retries once.
+    /// </summary>
+    private async Task<PolledMessagesRental> PollGroupMessagesRentedAsync(Identifier streamId, Identifier topicId,
+        Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit, CancellationToken token)
+    {
+        var key = new GroupKey(streamId, topicId, consumer.ConsumerId);
+        if (!_groupState.HasAssignment(key))
+        {
+            await SyncGroupAssignmentAsync(streamId, topicId, consumer.ConsumerId, token);
+        }
+
+        for (var attempt = 0; attempt < VsrGroupPollMaxAttempts; attempt++)
+        {
+            if (_groupState.NextGroupPartition(key) is not { } partitionId)
+            {
+                if (!_groupState.IsRegistered(key))
+                {
+                    throw VsrError.Exception(VsrError.CONSUMER_GROUP_MEMBER_NOT_FOUND,
+                        $"Client is not a member of consumer group {consumer.ConsumerId} on topic {topicId}.");
+                }
+
+                return EmptyPolledMessages;
+            }
+
+            PolledMessagesRental? rental = null;
+            try
+            {
+                rental = await PollPartitionMessagesRentedAsync(streamId, topicId, partitionId, consumer,
+                    pollingStrategy, count, autoCommit, token);
+            }
+            catch (IggyInvalidStatusCodeException e) when (e is
+            {
+                StatusCode: VsrError.CONSUMER_GROUP_PARTITION_NOT_OWNED,
+                FromServer: true
+            })
+            {
+                // Both fence shapes - the typed error and the sentinel an empty poll carries - land on the same
+                // re-sync below.
+            }
+
+            if (rental is not null)
+            {
+                if (rental.Messages.Count != 0 || rental.PartitionId != VsrResyncRequiredPartitionSentinel)
+                {
+                    return rental;
+                }
+
+                rental.Dispose();
+            }
+
+            _groupState.InvalidateAssignment(key);
+            await SyncGroupAssignmentAsync(streamId, topicId, consumer.ConsumerId, token);
+        }
+
+        return EmptyPolledMessages;
+    }
+
+    /// <summary>
+    ///     Pulls the requesting member's assignment from the coordinator into the cache. An empty reply means the
+    ///     client is not a member: the coordinator answers with an assignment header for any member, including
+    ///     one holding zero partitions.
+    /// </summary>
+    private async Task SyncGroupAssignmentAsync(Identifier streamId, Identifier topicId, Identifier groupId,
+        CancellationToken token)
+    {
+        var message = TcpContracts.GetGroup(streamId, topicId, groupId);
+        var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length];
+        TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.SYNC_CONSUMER_GROUP_CODE);
+
+        using IMemoryOwner<byte> responseBuffer = await SendWithResponseAsync(payload, token);
+
+        var key = new GroupKey(streamId, topicId, groupId);
+        if (responseBuffer.Memory.Length == 0)
+        {
+            // Deregistering is the only thing that observes a server-side removal (group deleted, member
+            // evicted); without it membership latches true and every later poll returns empty.
+            _groupState.DeregisterGroup(key);
+
+            return;
+        }
+
+        var assignment = SyncConsumerGroupAssignment.Decode(responseBuffer.Memory.Span);
+        _groupState.RegisterGroup(key, streamId, topicId, groupId);
+        _groupState.SetAssignment(key, assignment.Generation, assignment.Partitions);
+    }
+
+    /// <summary>
+    ///     Re-syncs every joined group so a widened assignment (a partition-count change, say) is picked up
+    ///     without first hitting an ownership fence. One failing group is logged and skipped so it cannot stall
+    ///     the rest.
+    /// </summary>
+    private async Task RefreshGroupAssignmentsAsync(CancellationToken token)
+    {
+        foreach (var group in _groupState.RegisteredGroups())
+        {
+            try
+            {
+                await SyncGroupAssignmentAsync(group.StreamId, group.TopicId, group.GroupId, token);
+            }
+            catch (Exception e) when (e is not OperationCanceledException)
+            {
+                _logger.LogWarning(e,
+                    "Failed to refresh the consumer group assignment for {StreamId}|{TopicId}|{GroupId}",
+                    group.StreamId, group.TopicId, group.GroupId);
+            }
+        }
+    }
+
+
+    /// <summary>
+    ///     Points the client at the current leader when it is not the node this connection is on, leaving the
+    ///     stream closed for the caller to reconnect. The redirect budget is client-wide: the connect loop and
+    ///     the transient failover path spend the same counter, and it is refunded as soon as a roster check
+    ///     lands on the leader.
+    /// </summary>
+    private async Task<bool> RedirectAsync(CancellationToken token)
+    {
+        // The probe issues a request of its own, which takes the sending semaphore, so it cannot run under that
+        // lock. Only the commit below does.
+        var currentLeaderNode = await GetCurrentLeaderNodeAsync(token);
+        if (currentLeaderNode == null)
+        {
+            Interlocked.Exchange(ref _leaderRedirectCount, 0);
+            return false;
+        }
+
+        var leaderAddress = ServerAddress.HostPort(currentLeaderNode.Ip, currentLeaderNode.Endpoints.Tcp);
+        if (ServerAddress.IsSame(leaderAddress, _currentAddress))
+        {
+            Interlocked.Exchange(ref _leaderRedirectCount, 0);
+            return false;
+        }
+
+        // Classic clients share this path and were never capped. Their only refund sites are the two roster
+        // checks above, so spending the VSR budget here would latch a classic client off the roster for good
+        // after a few genuine redirects.
+        if (_isVsr && Interlocked.Increment(ref _leaderRedirectCount) > VsrMaxLeaderRedirects)
+        {
+            _logger.LogWarning("Maximum leader redirections reached, continuing on {Address}", _currentAddress);
+            return false;
+        }
+
+        _logger.LogInformation("Leader address changed. Trying to reconnect to {Address}",
+            leaderAddress);
+
+        // The address move and the drop are one step: a reader that saw the new address but the old stream would
+        // find every later redirect short-circuited by the address check above, with no path back to the leader.
+        await _sendingSemaphore.WaitAsync(token);
+        try
+        {
+            _currentAddress = leaderAddress;
+            DropVsrConnectionLocked(_stream);
+        }
+        finally
+        {
+            _sendingSemaphore.Release();
+        }
+
+        return true;
+    }
+
+    private async Task<ClusterNode?> GetCurrentLeaderNodeAsync(CancellationToken token)
+    {
+        var leaderlessDeadline = Environment.TickCount64 + VsrLeaderlessWaitMs;
+        Interlocked.Increment(ref _leaderProbeDepth);
+        try
+        {
+            while (true)
+            {
+                var clusterMetadata = await GetClusterMetadataAsync(token);
+                if (clusterMetadata == null)
+                {
+                    return null;
+                }
+
+                if (clusterMetadata.Nodes.Count() == 1)
+                {
+                    return null;
+                }
+
+                var leaderNode = clusterMetadata.Nodes.FirstOrDefault(x =>
+                    x.Role == ClusterNodeRole.Leader && (!_isVsr || x.Status == ClusterNodeStatus.Healthy));
+                if (leaderNode != null)
+                {
+                    return leaderNode;
+                }
+
+                if (!_isVsr)
+                {
+                    throw new MissingLeaderException();
+                }
+
+                if (Environment.TickCount64 >= leaderlessDeadline)
+                {
+                    _logger.LogWarning("No leader in the cluster metadata after {WaitMs} ms, continuing on {Address}",
+                        VsrLeaderlessWaitMs, _currentAddress);
+
+                    return null;
+                }
+
+                await Task.Delay(VsrLeaderlessPollMs, token);
+            }
+        }
+        // todo: change after error refactoring, error code 5 is for feature not supported
+        catch (IggyInvalidStatusCodeException e) when (e is { StatusCode: VsrError.FEATURE_UNAVAILABLE, FromServer: true })
+        {
+            return null;
+        }
+        catch (Exception e) when (_isVsr && e is not OperationCanceledException)
+        {
+            _logger.LogWarning(e, "Failed to read the cluster metadata, continuing on {Address}", _currentAddress);
+
+            return null;
+        }
+        finally
+        {
+            Interlocked.Decrement(ref _leaderProbeDepth);
+        }
+    }
+
+    /// <summary>
+    ///     Sends a consensus-framed request. The call sites still build the classic
+    ///     <c>[size u32][code u32][body]</c> buffer, so the code is read back from it here and the body is written
+    ///     right after the 256-byte consensus header - two writes, no concatenation.
+    /// </summary>
+    /// <remarks>
+    ///     One deadline bounds the whole request across transient replays AND leader failovers. Login and
+    ///     register replay on this connection for the whole budget instead: the connect flow owns leader
+    ///     redirection for the handshake, and reconnecting from underneath it would recurse.
+    /// </remarks>
+    private async Task<IMemoryOwner<byte>> SendRawVsrAsync(ReadOnlyMemory<byte> payload, CancellationToken token)
+    {
+        var code = (int)BinaryPrimitives.ReadUInt32LittleEndian(payload.Span.Slice(4, 4));
+        ReadOnlyMemory<byte> body = payload[8..];
+        var isLoginRegister = code is CommandCodes.LOGIN_REGISTER_CODE or CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE;
+        var overallDeadline = Environment.TickCount64 + VsrRequestTimeoutMs;
+        var headerBuffer = ArrayPool<byte>.Shared.Rent(VsrHeader.HEADER_SIZE);
+        Memory<byte> header = headerBuffer.AsMemory(0, VsrHeader.HEADER_SIZE);
+        var requestEncoded = false;
+        TcpConnectionStream? lastStream = null;
+
+        try
+        {
+            while (true)
+            {
+                var transientDeadline = isLoginRegister
+                    ? overallDeadline
+                    : Math.Min(overallDeadline, Environment.TickCount64 + VsrTransientFailoverCheckMs);
+
+                var attempt = await SendVsrAttemptAsync(code, body, header, transientDeadline, overallDeadline,
+                    token);
+                requestEncoded |= attempt.Encoded;
+                lastStream = attempt.Stream;
+
+                if (attempt.Error is null)
+                {
+                    // A roster read taken by RedirectAsync must not refund the budget it is about to be charged
+                    // against, or the cap can never be reached.
+                    if (Volatile.Read(ref _leaderRedirectCount) != 0 && Volatile.Read(ref _leaderProbeDepth) == 0)
+                    {
+                        Interlocked.Exchange(ref _leaderRedirectCount, 0);
+                    }
+
+                    return attempt.Response!;
+                }
+
+                if (attempt.Error is IggyInvalidStatusCodeException
+                    { StatusCode: VsrError.TRANSIENT_NOT_ACCEPTED, FromServer: true }
+                    && !isLoginRegister
+                    && Environment.TickCount64 < overallDeadline)
+                {
+                    if (await RedirectAsync(token))
+                    {
+                        await ConnectAsync(token);
+                    }
+
+                    continue;
+                }
+
+                if (attempt.Error is VsrSessionEvictedException evicted)
+                {
+                    if (attempt.RequestStarted && !IsReplaySafeRead(code, isLoginRegister, body.Span))
+                    {
+                        throw new VsrRequestOutcomeUnknownException(evicted);
+                    }
+
+                    ExceptionDispatchInfo.Throw(evicted.Verdict);
+                }
+
+                if (attempt.RequestStarted
+                    && !IsReplaySafeRead(code, isLoginRegister, body.Span)
+                    && !IsDefinitiveVerdict(attempt.Error))
+                {
+                    throw new VsrRequestOutcomeUnknownException(attempt.Error);
+                }
+
+                ExceptionDispatchInfo.Throw(attempt.Error);
+            }
+        }
+        catch (OperationCanceledException)
+        {
+            if (requestEncoded)
+            {
+                await DropVsrConnectionAsync(lastStream);
+            }
+
+            throw;
+        }
+        finally
+        {
+            ArrayPool<byte>.Shared.Return(headerBuffer);
+        }
+    }
+
+    /// <summary>
+    ///     Whether losing the connection mid-request leaves nothing for the server to deduplicate, so the request
+    ///     can be re-issued on a fresh session by the reconnect path instead of failing the caller.
+    /// </summary>
+    private static bool IsReplaySafeRead(int code, bool isLoginRegister, ReadOnlySpan<byte> body)
+    {
+        // A register lost mid-flight is replayable: the retry re-arms the session under a fresh client id, so
+        // it cannot be mistaken for the first one. At worst the server keeps an entry nobody binds, which it
+        // ages out. Reporting an unknown outcome here instead would deny the caller the retry that is in fact
+        // the only correct response.
+        if (isLoginRegister)
+        {
+            return true;
+        }
+
+        var operation = VsrOperations.ForCode(code);
+
+        // A consumer offset write carries an absolute offset and the server applies it as an unconditional
+        // overwrite, on a plane that keeps no client table to dedup against, so a replay lands on the same
+        // value. Denying the retry here reports an unknown outcome for a blip on an offset commit, which
+        // takes down the consume loop over a write that was safe to repeat.
+        if (operation is VsrOperation.StoreConsumerOffset or VsrOperation.StoreConsumerOffset2
+            or VsrOperation.DeleteConsumerOffset or VsrOperation.DeleteConsumerOffset2)
+        {
+            return true;
+        }
+
+        if (operation != VsrOperation.NonReplicated)
+        {
+            return false;
+        }
+
+        // A poll that auto-commits moves the consumer offset server-side, so a reply lost after the commit
+        // would make the replay start past a batch the caller never saw. auto_commit is the last body byte.
+        if (code == CommandCodes.POLL_MESSAGES_CODE)
+        {
+            return body.Length > 0 && body[^1] == 0;
+        }
+
+        // Everything else non-replicated is replay-safe only if this build knows it to be a read. An unmapped
+        // code also lands on NonReplicated, and re-sending one the server implements as a mutation would apply
+        // it twice with nothing to deduplicate against.
+        return NonReplicatedReadCodes.Contains(code);
+    }
+
+    /// <summary>
+    ///     Whether the failure carries the server's verdict on this request. A lost connection, a reply frame
+    ///     the client refused or discarded, and a NOT_COMMITTED that outlived its replay deadline all leave the
+    ///     outcome of a request the server may still commit unknowable.
+    /// </summary>
+    private static bool IsDefinitiveVerdict(Exception error)
+    {
+        return error is IggyInvalidStatusCodeException
+        {
+            FromServer: true,
+            StatusCode: not VsrError.TRANSIENT_NOT_COMMITTED
+        };
+    }
+
+    /// <summary>
+    ///     One attempt on the current connection: encode the header into <paramref name="header" />, write the
+    ///     frame, and replay it - same session, same request id - while the server answers transiently.
+    /// </summary>
+    private async ValueTask<VsrAttempt> SendVsrAttemptAsync(int code, ReadOnlyMemory<byte> body, Memory<byte> header,
+        long transientDeadline, long readDeadline, CancellationToken token)
+    {
+        await _sendingSemaphore.WaitAsync(token);
+
+        var encoded = false;
+        var requestStarted = false;
+        byte[]? frameBuffer = null;
+
+        // Read the stream once for the whole attempt. Nothing may swap the field without the sending lock this
+        // call holds, so the frame and its reply cannot be split across two sockets, and a teardown after the
+        // lock is gone can tell this connection from a replacement a reconnect installed since.
+        TcpConnectionStream stream = _stream;
+        try
+        {
+            // A small request goes out as one write. Two writes cost two syscalls and, with Nagle disabled, two
+            // TCP segments (two TLS records when encrypted) for what the reference encoder sends as a single
+            // contiguous frame. Above the threshold the copy costs more than the extra write saves. Renting
+            // inside the try keeps the semaphore paired with its release even if the pool throws.
+            if (body.Length <= VsrContiguousFrameLimit)
+            {
+                frameBuffer = ArrayPool<byte>.Shared.Rent(VsrHeader.HEADER_SIZE + body.Length);
+            }
+
+            VsrHeader.EncodeRequestHeader(header.Span, _consensusSession, code, body.Span);
+
+            encoded = true;
+
+            var frame = Memory<byte>.Empty;
+            if (frameBuffer is not null)
+            {
+                frame = frameBuffer.AsMemory(0, VsrHeader.HEADER_SIZE + body.Length);
+                header.CopyTo(frame);
+                body.CopyTo(frame[VsrHeader.HEADER_SIZE..]);
+            }
+
+            while (true)
+            {
+                try
+                {
+                    // Everything that fails without reaching the socket has to fail before this point: past it a
+                    // failure is reported as an outcome the server alone knows, which for a replicated write
+                    // tells the caller its request may have committed twice.
+                    token.ThrowIfCancellationRequested();
+                    requestStarted = true;
+
+                    if (frameBuffer is not null)
+                    {
+                        await stream.SendAsync(frame, token);
+                    }
+                    else
+                    {
+                        await stream.SendAsync(header, token);
+                        await stream.SendAsync(body, token);
+                    }
+
+                    await stream.FlushAsync(token);
+
+                    IMemoryOwner<byte> response = await ReadVsrReplyAsync(stream, readDeadline, token);
+
+                    return VsrAttempt.Ok(response, stream);
+                }
+                catch (IggyInvalidStatusCodeException e) when (IsReplayableTransient(e, transientDeadline,
+                                                                   readDeadline))
+                {
+                    var governingDeadline = e.StatusCode == VsrError.TRANSIENT_NOT_COMMITTED
+                        ? readDeadline
+                        : transientDeadline;
+                    var remaining = governingDeadline - Environment.TickCount64;
+                    await Task.Delay((int)Math.Clamp(remaining, 0, VsrTransientRetryIntervalMs), token);
+                }
+                catch (Exception e) when (IsConnectionException(e))
+                {
+                    DropVsrConnectionLocked(stream);
+
+                    return VsrAttempt.Failed(encoded, e, requestStarted, stream);
+                }
+                catch (OperationCanceledException e)
+                {
+                    DropVsrConnectionLocked(stream);
+
+                    return VsrAttempt.Failed(encoded, e, requestStarted, stream);
+                }
+                catch (Exception e)
+                {
+                    return VsrAttempt.Failed(encoded, e, requestStarted, stream);
+                }
+            }
+        }
+        catch (OperationCanceledException e)
+        {
+            if (encoded)
+            {
+                DropVsrConnectionLocked(stream);
+            }
+
+            return VsrAttempt.Failed(encoded, e, requestStarted, stream);
+        }
+        catch (Exception e)
+        {
+            return VsrAttempt.Failed(encoded, e, requestStarted, stream);
+        }
+        finally
+        {
+            if (frameBuffer is not null)
+            {
+                ArrayPool<byte>.Shared.Return(frameBuffer);
+            }
+
+            _sendingSemaphore.Release();
+        }
+    }
+
+    private async Task<IMemoryOwner<byte>> ReadVsrReplyAsync(TcpConnectionStream stream, long readDeadline,
+        CancellationToken token)
+    {
+        var remaining = readDeadline - Environment.TickCount64;
+        if (remaining <= 0)
+        {
+            throw new IOException($"Timed out after {VsrRequestTimeoutMs} ms waiting for a consensus reply.");
+        }
+
+        // One timer for the whole reply: the deadline covers the frame, not each partial read, so a per-read
+        // source would both re-arm the budget and allocate a timer per socket read.
+        using var readCancellation = CancellationTokenSource.CreateLinkedTokenSource(token);
+        readCancellation.CancelAfter((int)Math.Min(remaining, VsrRequestTimeoutMs));
+
+        await ReadExactVsrAsync(stream, _vsrReplyHeaderBuffer, readCancellation.Token, token);
+
+        var command = VsrHeader.PeekCommand(_vsrReplyHeaderBuffer);
+        if (command == Command2.Eviction)
+        {
+            var eviction = VsrHeader.ReadEviction(_vsrReplyHeaderBuffer);
+            _logger.LogWarning("Consensus session evicted by the server: {Reason}", eviction.Reason);
+            DropVsrConnectionLocked(stream);
+
+            throw new VsrSessionEvictedException(VsrReplyDecoder.ToException(eviction));
+        }
+
+        if (command != Command2.Reply)
+        {
+            // Neither a reply nor an eviction: this frame was never an answer to the outstanding request, so
+            // whatever the peer does send for it would be read as the next request's reply and handed to the
+            // wrong caller. The size field of a frame the client cannot model is no basis for resynchronising.
+            DropVsrConnectionLocked(stream);
+
+            throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                $"Unexpected consensus frame {command} on a client connection.");
+        }
+
+        int bodySize;
+        try
+        {
+            bodySize = VsrReplyDecoder.ReadBodySize(_vsrReplyHeaderBuffer);
+            if (VsrHeader.HEADER_SIZE + (long)bodySize > _configuration.MaxResponseFrameSize)
+            {
+                throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                    $"Reply frame of {VsrHeader.HEADER_SIZE + bodySize} bytes exceeds the configured maximum of " +
+                    $"{_configuration.MaxResponseFrameSize} bytes.");
+            }
+        }
+        catch
+        {
+            // An announced size the client refuses to read - undersized, oversized - leaves the body on the
+            // wire, so the stream no longer sits on a frame boundary and the next reply would decode body bytes
+            // as a header.
+            DropVsrConnectionLocked(stream);
+
+            throw;
+        }
+
+        if (bodySize == 0)
+        {
+            VsrReplyDecoder.Decode(_vsrReplyHeaderBuffer, ReadOnlyMemory<byte>.Empty);
+
+            return EmptyMemoryOwner.Instance;
+        }
+
+        var buffer = ArrayPool<byte>.Shared.Rent(bodySize);
+        try
+        {
+            await ReadExactVsrAsync(stream, buffer.AsMemory(0, bodySize), readCancellation.Token, token);
+            ReadOnlyMemory<byte> decoded = VsrReplyDecoder.Decode(_vsrReplyHeaderBuffer, buffer.AsMemory(0, bodySize));
+            if (decoded.IsEmpty)
+            {
+                ArrayPool<byte>.Shared.Return(buffer);
+
+                return EmptyMemoryOwner.Instance;
+            }
+
+            // The decoded payload is always a suffix of the body - the funnel only strips the leading
+            // committed result section.
+            return new PooledMemoryOwner(buffer, bodySize - decoded.Length, decoded.Length);
+        }
+        catch
+        {
+            ArrayPool<byte>.Shared.Return(buffer);
+            throw;
+        }
+    }
+
+    private async ValueTask ReadExactVsrAsync(TcpConnectionStream stream, Memory<byte> buffer, CancellationToken readToken,
+        CancellationToken token)
+    {
+        var totalRead = 0;
+        while (totalRead < buffer.Length)
+        {
+            int readBytes;
+            try
+            {
+                readBytes = await stream.ReadAsync(buffer[totalRead..], readToken);
+            }
+            catch (OperationCanceledException) when (!token.IsCancellationRequested)
+            {
+                throw new IOException($"Timed out after {VsrRequestTimeoutMs} ms waiting for a consensus reply.");
+            }
+
+            if (readBytes == 0)
+            {
+                throw new IggyZeroBytesException();
+            }
+
+            totalRead += readBytes;
+        }
+    }
+
+    /// <summary>
+    ///     Drops the consensus session and the group state scoped to it. Consumer-group assignments are fenced by
+    ///     a generation the coordinator tracks per session, so carrying them into a new session would fence every
+    ///     poll until the first re-sync. The balanced cursors and the partition counts survive: neither is bound
+    ///     to a session, and dropping them costs a metadata round trip per topic on the next produce.
+    /// </summary>
+    private void ResetConsensusSession()
+    {
+        _consensusSession.Reset();
+        _groupState.ClearSessionScoped();
+    }
+
+    /// <summary>
+    ///     Resets the session on behalf of a caller that does not hold the sending lock, so no request can be
+    ///     encoding against the identity while it is re-armed.
+    /// </summary>
+    private async ValueTask ResetConsensusSessionAsync()
+    {
+        // Taking a disposed semaphore would replace the failure the caller is about to rethrow with an
+        // ObjectDisposedException, and a disposed client has nothing left to fence. Dispose can still land
+        // between the check and the wait, so the wait itself has to tolerate it.
+        if (_disposed || !await TryEnterSendingSemaphoreAsync())
+        {
+            return;
+        }
+
+        try
+        {
+            ResetConsensusSession();
+        }
+        finally
+        {
+            _sendingSemaphore.Release();
+        }
+    }
+
+    /// <summary>
+    ///     Takes the sending lock for a teardown that must not fail. Returns false once the client is disposed:
+    ///     the caller is unwinding an earlier failure and has nothing left to fence.
+    /// </summary>
+    private async ValueTask<bool> TryEnterSendingSemaphoreAsync()
+    {
+        try
+        {
+            await _sendingSemaphore.WaitAsync(CancellationToken.None);
+            return true;
+        }
+        catch (ObjectDisposedException)
+        {
+            return false;
+        }
+    }
+
+    /// <summary>
+    ///     Drops the connection along with the session. A late or half-read reply would desync the framing of the
+    ///     next request, so the stream cannot be reused. The caller must hold <see cref="_sendingSemaphore" />,
+    ///     which owns every write to <see cref="_stream" />.
+    /// </summary>
+    /// <param name="stream">
+    ///     The connection the caller was using. A reconnect that completed in the meantime already closed it and
+    ///     re-armed the session, so dropping anything but the live one would tear down a healthy replacement.
+    /// </param>
+    private void DropVsrConnectionLocked(TcpConnectionStream? stream)
+    {
+        if (!ReferenceEquals(_stream, stream))
+        {
+            return;
+        }
+
+        ResetConsensusSession();
+        _stream?.Close();
+        SetConnectionStateAsync(ConnectionState.Disconnected);
+    }
+
+    /// <summary>Drops the connection on behalf of a caller that no longer holds the sending lock.</summary>
+    private async ValueTask DropVsrConnectionAsync(TcpConnectionStream? stream)
+    {
+        // Dispose already closed the stream, and taking a disposed semaphore here would replace the
+        // cancellation the caller is about to rethrow with an ObjectDisposedException. Dispose can still land
+        // between the check and the wait, so the wait itself has to tolerate it.
+        // The request this drop belongs to was cancelled; the drop itself still has to run to completion.
+        if (_disposed || !await TryEnterSendingSemaphoreAsync())
+        {
+            return;
+        }
+
+        try
+        {
+            DropVsrConnectionLocked(stream);
+        }
+        finally
+        {
+            _sendingSemaphore.Release();
+        }
+    }
+
+    private static bool IsReplayableTransient(IggyInvalidStatusCodeException error, long transientDeadline,
+        long readDeadline)
+    {
+        if (!error.FromServer)
+        {
+            return false;
+        }
+
+        return error.StatusCode switch
+        {
+            VsrError.TRANSIENT_NOT_COMMITTED => Environment.TickCount64 < readDeadline,
+            VsrError.TRANSIENT_NOT_ACCEPTED => Environment.TickCount64 < transientDeadline,
+            _ => false
+        };
+    }
+
+    /// <summary>Outcome of one <see cref="SendVsrAttemptAsync" /> call on the current connection.</summary>
+    /// <param name="Encoded">Whether the header was encoded, i.e. whether a request id may have been consumed.</param>
+    /// <param name="Response">The decoded reply payload, non-null exactly when <paramref name="Error" /> is null.</param>
+    /// <param name="Error">The failure that ended the attempt, or null on success.</param>
+    /// <param name="RequestStarted">
+    ///     Whether any byte of the frame was written, which makes the server-side outcome unknowable on failure.
+    /// </param>
+    /// <param name="Stream">
+    ///     The connection the attempt ran on, so a caller that drops it after releasing the sending lock can tell
+    ///     its own connection from a replacement a reconnect installed since.
+    /// </param>
+    private readonly record struct VsrAttempt(
+        bool Encoded,
+        IMemoryOwner<byte>? Response,
+        Exception? Error,
+        bool RequestStarted,
+        TcpConnectionStream? Stream)
+    {
+        public static VsrAttempt Ok(IMemoryOwner<byte> response, TcpConnectionStream stream)
+        {
+            return new VsrAttempt(true, response, null, true, stream);
+        }
+
+        public static VsrAttempt Failed(bool encoded, Exception error, bool requestStarted,
+            TcpConnectionStream? stream)
+        {
+            return new VsrAttempt(encoded, null, error, requestStarted, stream);
+        }
+    }
+
+    /// <summary>Owns a pooled buffer while exposing only the decoded payload slice inside it.</summary>
+    internal sealed class PooledMemoryOwner(byte[] buffer, int start, int length) : IMemoryOwner<byte>
+    {
+        private int _disposed;
+
+        public Memory<byte> Memory => buffer.AsMemory(start, length);
+
+        public void Dispose()
+        {
+            if (Interlocked.Exchange(ref _disposed, 1) == 0)
+            {
+                ArrayPool<byte>.Shared.Return(buffer);
+            }
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
index a2071c351..c23570b0c 100644
--- a/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
+++ b/foreign/csharp/Iggy_SDK/IggyClient/Implementations/TcpMessageStream.cs
@@ -35,15 +35,17 @@ using Apache.Iggy.Kinds;
 using Apache.Iggy.Mappers;
 using Apache.Iggy.Messages;
 using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
 using Microsoft.Extensions.Logging;
 using Partitioning = Apache.Iggy.Kinds.Partitioning;
 
 namespace Apache.Iggy.IggyClient.Implementations;
 
 /// <summary>
-///     A TCP client for interacting with the Iggy server.
+///     A TCP client for interacting with the Iggy server. The consensus (VSR) framing, leader redirection and
+///     register handshake live in <c>TcpMessageStream.Vsr.cs</c>.
 /// </summary>
-public sealed class TcpMessageStream : IIggyClient
+public sealed partial class TcpMessageStream : IIggyClient
 {
     private const int InvalidCommandStatus = 3;
 
@@ -58,20 +60,32 @@ public sealed class TcpMessageStream : IIggyClient
 
     private readonly IggyClientConfigurator _configuration;
     private readonly EventAggregator<ConnectionStateChangedEventArgs> _connectionEvents;
+    private readonly SemaphoreSlim _connectGate = new(1, 1);
     private readonly SemaphoreSlim _connectionSemaphore;
     private readonly ILogger<TcpMessageStream> _logger;
     private readonly byte[] _responseHeaderBuffer = new byte[BufferSizes.EXPECTED_RESPONSE_SIZE];
     private readonly SemaphoreSlim _sendingSemaphore;
     private string _currentAddress = string.Empty;
     private X509Certificate2Collection _customCaStore = [];
-    private bool _isConnecting;
+    private volatile bool _disposed;
+    private int _isConnecting;
     private DateTimeOffset _lastConnectionTime;
-    private ConnectionState _state = ConnectionState.Disconnected;
+    private int _leaderRedirectCount;
+
+    // Both are written by the connect and redirect paths, which do not hold the sending semaphore the request
+    // paths read them under, so they are accessed through Interlocked rather than as plain fields. Losing an
+    // update to the skip flag leaves a connection reporting Connected that never authenticated; losing one to
+    // the redirect counter over- or under-spends the redirect budget.
+    private int _skipAutoLoginOnce;
+    private volatile ConnectionState _state = ConnectionState.Disconnected;
     private TcpConnectionStream _stream = null!;
 
+    private bool IsConnecting => Volatile.Read(ref _isConnecting) != 0;
+
     internal TcpMessageStream(IggyClientConfigurator configuration, ILoggerFactory loggerFactory)
     {
         _configuration = configuration;
+        _isVsr = configuration.WireProtocol == WireProtocol.Vsr;
         _logger = loggerFactory.CreateLogger<TcpMessageStream>();
         _sendingSemaphore = new SemaphoreSlim(1, 1);
         _connectionSemaphore = new SemaphoreSlim(1, 1);
@@ -84,10 +98,14 @@ public sealed class TcpMessageStream : IIggyClient
     /// </summary>
     public void Dispose()
     {
+        _disposed = true;
         _stream?.Close();
         _stream?.Dispose();
+
+        SetConnectionStateAsync(ConnectionState.Disconnected);
         _sendingSemaphore.Dispose();
         _connectionSemaphore.Dispose();
+        _connectGate.Dispose();
         _connectionEvents.Clear();
     }
 
@@ -273,6 +291,7 @@ public sealed class TcpMessageStream : IIggyClient
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.DELETE_TOPIC_CODE);
 
         await SendAckAsync(payload, token);
+        _groupState.InvalidatePartitionCount(new TopicKey(streamId, topicId));
     }
 
     /// <inheritdoc />
@@ -290,6 +309,11 @@ public sealed class TcpMessageStream : IIggyClient
     public Task SendMessagesAsync(Identifier streamId, Identifier topicId, Partitioning partitioning,
         IList<Message> messages, CancellationToken token = default)
     {
+        if (NeedsClientSidePartitioning(partitioning))
+        {
+            return SendMessagesResolvedAsync(streamId, topicId, partitioning, messages, token);
+        }
+
         return SendMessagesCoreAsync(streamId, topicId, partitioning, AsSpan(messages), token);
     }
 
@@ -297,6 +321,11 @@ public sealed class TcpMessageStream : IIggyClient
     public Task SendMessagesAsync(Identifier streamId, Identifier topicId, Partitioning partitioning,
         Message message, CancellationToken token = default)
     {
+        if (NeedsClientSidePartitioning(partitioning))
+        {
+            return SendMessagesResolvedAsync(streamId, topicId, partitioning, [message], token);
+        }
+
         ReadOnlySpan<Message> span = [message];
         return SendMessagesCoreAsync(streamId, topicId, partitioning, span, token);
     }
@@ -324,38 +353,23 @@ public sealed class TcpMessageStream : IIggyClient
     }
 
     /// <inheritdoc />
-    public async Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId,
+    public Task<PolledMessagesRental> PollMessagesRentedAsync(Identifier streamId, Identifier topicId,
         uint? partitionId,
         Consumer consumer,
         PollingStrategy pollingStrategy, uint count, bool autoCommit, CancellationToken token = default)
     {
         ThrowIfAutoCommitWithEncryptor(autoCommit);
 
-        var messageBufferSize = CalculateMessageBufferSize(streamId, topicId, consumer);
-        var payloadBufferSize = CalculatePayloadBufferSize(messageBufferSize);
-        var payload = ArrayPool<byte>.Shared.Rent(payloadBufferSize);
-        IMemoryOwner<byte>? responseBuffer = null;
-
-        try
-        {
-            TcpContracts.GetMessages(payload.AsSpan().Slice(8, messageBufferSize), consumer, streamId,
-                topicId, pollingStrategy, count, autoCommit, partitionId);
-            BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[..4], messageBufferSize + 4);
-            BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[4..8], CommandCodes.POLL_MESSAGES_CODE);
-
-            responseBuffer = await SendWithResponseAsync(payload.AsMemory(0, payloadBufferSize), token);
-            return BinaryMapper.MapRentedMessages(responseBuffer.Memory, responseBuffer,
-                _configuration.MessageEncryptor);
-        }
-        catch
+        // Under VSR the broker routes explicit partitions only, so a group poll picks one of the member's
+        // assigned partitions client-side.
+        if (_isVsr && consumer.Type == ConsumerType.ConsumerGroup && partitionId is null)
         {
-            responseBuffer?.Dispose();
-            throw;
-        }
-        finally
-        {
-            ArrayPool<byte>.Shared.Return(payload);
+            return PollGroupMessagesRentedAsync(streamId, topicId, consumer, pollingStrategy, count, autoCommit,
+                token);
         }
+
+        return PollPartitionMessagesRentedAsync(streamId, topicId, partitionId, consumer, pollingStrategy, count,
+            autoCommit, token);
     }
 
     /// <inheritdoc />
@@ -462,6 +476,7 @@ public sealed class TcpMessageStream : IIggyClient
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.DELETE_CONSUMER_GROUP_CODE);
 
         await SendAckAsync(payload, token);
+        _groupState.DeregisterGroup(new GroupKey(streamId, topicId, groupId));
     }
 
     /// <inheritdoc />
@@ -473,6 +488,10 @@ public sealed class TcpMessageStream : IIggyClient
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.JOIN_CONSUMER_GROUP_CODE);
 
         await SendAckAsync(payload, token);
+
+        // A join rebalances the group, so whatever this client holds for it is a generation behind and every
+        // poll under it would be fenced until the first re-sync.
+        _groupState.InvalidateAssignment(new GroupKey(streamId, topicId, groupId));
     }
 
     /// <inheritdoc />
@@ -484,6 +503,7 @@ public sealed class TcpMessageStream : IIggyClient
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LEAVE_CONSUMER_GROUP_CODE);
 
         await SendAckAsync(payload, token);
+        _groupState.DeregisterGroup(new GroupKey(streamId, topicId, groupId));
     }
 
     /// <inheritdoc />
@@ -495,6 +515,7 @@ public sealed class TcpMessageStream : IIggyClient
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.DELETE_PARTITIONS_CODE);
 
         await SendAckAsync(payload, token);
+        _groupState.InvalidatePartitionCount(new TopicKey(streamId, topicId));
     }
 
     /// <inheritdoc />
@@ -506,6 +527,7 @@ public sealed class TcpMessageStream : IIggyClient
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.CREATE_PARTITIONS_CODE);
 
         await SendAckAsync(payload, token);
+        _groupState.InvalidatePartitionCount(new TopicKey(streamId, topicId));
     }
 
     /// <inheritdoc />
@@ -578,6 +600,11 @@ public sealed class TcpMessageStream : IIggyClient
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.PING_CODE);
 
         await SendAckAsync(payload, token);
+
+        if (_isVsr)
+        {
+            await RefreshGroupAssignmentsAsync(token);
+        }
     }
 
     /// <inheritdoc />
@@ -621,20 +648,29 @@ public sealed class TcpMessageStream : IIggyClient
             return;
         }
 
-        if (_lastConnectionTime != DateTimeOffset.MinValue)
-        {
-            await Task.Delay(_configuration.ReconnectionSettings.InitialDelay, token);
-        }
-
-        SetConnectionStateAsync(ConnectionState.Connecting);
-        _isConnecting = true;
+        await _connectGate.WaitAsync(token);
+        Interlocked.Exchange(ref _isConnecting, 1);
         try
         {
+            if (_state is ConnectionState.Connected
+                or ConnectionState.Authenticating
+                or ConnectionState.Authenticated)
+            {
+                return;
+            }
+
+            if (_lastConnectionTime != DateTimeOffset.MinValue)
+            {
+                await Task.Delay(_configuration.ReconnectionSettings.InitialDelay, token);
+            }
+
+            SetConnectionStateAsync(ConnectionState.Connecting);
             await TryEstablishConnectionAsync(token);
         }
         finally
         {
-            _isConnecting = false;
+            Interlocked.Exchange(ref _isConnecting, 0);
+            _connectGate.Release();
         }
     }
 
@@ -775,8 +811,14 @@ public sealed class TcpMessageStream : IIggyClient
             throw new NotConnectedException();
         }
 
+        if (_isVsr)
+        {
+            return await LoginRegisterAsync(CommandCodes.LOGIN_REGISTER_CODE,
+                LoginRegister.Serialize(userName, password), token);
+        }
+
         // TODO: Add binary protocol version
-        var message = TcpContracts.LoginUser(userName, password, SdkVersion.Value, "csharp-sdk");
+        var message = TcpContracts.LoginUser(userName, password, SdkVersion.Value, LoginRegister.SDK_NAME);
         var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length];
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LOGIN_USER_CODE);
 
@@ -791,7 +833,7 @@ public sealed class TcpMessageStream : IIggyClient
         var userId = BinaryPrimitives.ReadInt32LittleEndian(responseBuffer.Memory.Span[..responseBuffer.Memory.Length]);
         SetConnectionStateAsync(ConnectionState.Authenticated);
 
-        if (await RedirectAsync(token))
+        if (!IsConnecting && await RedirectAsync(token))
         {
             await ConnectAsync(token);
             return await LoginUserAsync(userName, password, token);
@@ -808,7 +850,22 @@ public sealed class TcpMessageStream : IIggyClient
         var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length];
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LOGOUT_USER_CODE);
 
-        await SendAckAsync(payload, token);
+        try
+        {
+            await SendAckAsync(payload, token);
+        }
+        finally
+        {
+            if (_isVsr)
+            {
+                await ResetConsensusSessionAsync();
+
+                if (_state == ConnectionState.Authenticated)
+                {
+                    SetConnectionStateAsync(ConnectionState.Connected);
+                }
+            }
+        }
     }
 
     /// <inheritdoc />
@@ -860,6 +917,12 @@ public sealed class TcpMessageStream : IIggyClient
     /// <inheritdoc />
     public async Task<AuthResponse?> LoginWithPersonalAccessTokenAsync(string token, CancellationToken ct = default)
     {
+        if (_isVsr)
+        {
+            return await LoginRegisterAsync(CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE,
+                LoginRegister.SerializeWithPersonalAccessToken(token), ct);
+        }
+
         var message = TcpContracts.LoginWithPersonalAccessToken(token);
         var payload = new byte[4 + BufferSizes.INITIAL_BYTES_LENGTH + message.Length];
         TcpMessageStreamHelpers.CreatePayload(payload, message, CommandCodes.LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE);
@@ -876,7 +939,7 @@ public sealed class TcpMessageStream : IIggyClient
 
         SetConnectionStateAsync(ConnectionState.Authenticated);
 
-        if (await RedirectAsync(ct))
+        if (!IsConnecting && await RedirectAsync(ct))
         {
             await ConnectAsync(ct);
             return await LoginWithPersonalAccessTokenAsync(token, ct);
@@ -885,6 +948,43 @@ public sealed class TcpMessageStream : IIggyClient
         return new AuthResponse(userId, null);
     }
 
+    private async Task<PolledMessagesRental> PollPartitionMessagesRentedAsync(Identifier streamId, Identifier topicId,
+        uint? partitionId, Consumer consumer, PollingStrategy pollingStrategy, uint count, bool autoCommit,
+        CancellationToken token)
+    {
+        var messageBufferSize = CalculateMessageBufferSize(streamId, topicId, consumer);
+        var payloadBufferSize = CalculatePayloadBufferSize(messageBufferSize);
+        var payload = ArrayPool<byte>.Shared.Rent(payloadBufferSize);
+        IMemoryOwner<byte>? responseBuffer = null;
+
+        try
+        {
+            TcpContracts.GetMessages(payload.AsSpan().Slice(8, messageBufferSize), consumer, streamId,
+                topicId, pollingStrategy, count, autoCommit, partitionId);
+            BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[..4], messageBufferSize + 4);
+            BinaryPrimitives.WriteInt32LittleEndian(payload.AsSpan()[4..8], CommandCodes.POLL_MESSAGES_CODE);
+
+            responseBuffer = await SendWithResponseAsync(payload.AsMemory(0, payloadBufferSize), token);
+            if (responseBuffer.Memory.Length == 0)
+            {
+                responseBuffer.Dispose();
+                return EmptyPolledMessages;
+            }
+
+            return BinaryMapper.MapRentedMessages(responseBuffer.Memory, responseBuffer,
+                _configuration.MessageEncryptor);
+        }
+        catch
+        {
+            responseBuffer?.Dispose();
+            throw;
+        }
+        finally
+        {
+            ArrayPool<byte>.Shared.Return(payload);
+        }
+    }
+
     // Server-side autoCommit commits the batch offset before the client decrypts, so a decryption failure
     // would permanently skip the whole batch. IggyConsumer guards this too, but the raw poll is public and
     // bypasses that path. Opt out via IggyClientConfigurator.AllowAutoCommitWithEncryptor.
@@ -965,56 +1065,98 @@ public sealed class TcpMessageStream : IIggyClient
     private async Task TryEstablishConnectionAsync(CancellationToken token)
     {
         var retryCount = 0;
+        var redirects = 0;
         var delay = _configuration.ReconnectionSettings.InitialDelay;
         do
         {
-            // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
-            _stream?.Close();
-            _stream?.Dispose();
+            // The sending semaphore owns every write to _stream, so an in-flight request never observes the
+            // field changing between its write and its reply.
+            await _sendingSemaphore.WaitAsync(token);
+            try
+            {
+                _stream?.Dispose();
+
+                ResetConsensusSession();
+            }
+            finally
+            {
+                _sendingSemaphore.Release();
+            }
 
             if (string.IsNullOrEmpty(_currentAddress))
             {
                 _currentAddress = _configuration.BaseAddress;
             }
 
-            var urlPortSplitter = _currentAddress.Split(":");
-            if (urlPortSplitter.Length > 2)
+            if (!ServerAddress.TryParse(_currentAddress, out var host, out var port))
             {
                 throw new InvalidBaseAddressException();
             }
 
+            Socket? socket = null;
             try
             {
-                var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+                socket = new Socket(ServerAddress.AddressFamilyOf(host), SocketType.Stream, ProtocolType.Tcp);
                 socket.SendBufferSize = _configuration.SendBufferSize;
                 socket.ReceiveBufferSize = _configuration.ReceiveBufferSize;
 
-                await socket.ConnectAsync(urlPortSplitter[0], int.Parse(urlPortSplitter[1]), token);
+                // The protocol is request/reply on both wire protocols, so a write is always the last one before
+                // the client blocks on the answer and Nagle has nothing to coalesce it with - it only delays the
+                // trailing segment of a large request until the previous one is acked.
+                socket.NoDelay = true;
+
+                await socket.ConnectAsync(host, port, token);
 
                 socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
                 socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 5);
 
-                SetConnectionStateAsync(ConnectionState.Connected);
-                _lastConnectionTime = DateTimeOffset.UtcNow;
-
-                _stream = _configuration.TlsSettings.Enabled switch
+                TcpConnectionStream connectionStream = _configuration.TlsSettings.Enabled switch
                 {
                     true => await CreateSslStreamAndAuthenticate(socket, _configuration.TlsSettings),
                     false => new TcpConnectionStream(new NetworkStream(socket, true))
                 };
 
-                if (_configuration.AutoLoginSettings.Enabled)
+                await _sendingSemaphore.WaitAsync(token);
+                try
+                {
+                    _stream = connectionStream;
+                }
+                finally
+                {
+                    _sendingSemaphore.Release();
+                }
+
+                SetConnectionStateAsync(ConnectionState.Connected);
+                _lastConnectionTime = DateTimeOffset.UtcNow;
+
+                socket = null;
+
+                if (_isVsr && await RedirectAsync(token))
+                {
+                    await BackoffOrThrowAsync();
+                    continue;
+                }
+
+                if (_configuration.AutoLoginSettings.Enabled && !ConsumeSkipAutoLogin())
                 {
                     _logger.LogInformation("Auto login enabled. Trying to login with credentials: {Username}",
                         _configuration.AutoLoginSettings.Username);
                     await LoginUserAsync(_configuration.AutoLoginSettings.Username,
                         _configuration.AutoLoginSettings.Password, token);
+
+                    if (await RedirectAsync(token))
+                    {
+                        await BackoffOrThrowAsync();
+                        continue;
+                    }
                 }
 
                 break;
             }
             catch (Exception e)
             {
+                socket?.Dispose();
+
                 _logger.LogError(e, "Failed to connect");
 
                 if (!_configuration.ReconnectionSettings.Enabled ||
@@ -1045,37 +1187,38 @@ public sealed class TcpMessageStream : IIggyClient
                 await Task.Delay(delay, token);
             }
         } while (true);
-    }
 
-    private async Task<ClusterNode?> GetCurrentLeaderNodeAsync(CancellationToken token)
-    {
-        try
+        // A redirect restarts the loop without passing through the catch, so it spends no retry and waits for
+        // nothing. Its own budget rather than the reconnection one: following the roster to the leader is how a
+        // VSR connect succeeds, and it has to work with reconnection turned off.
+        async Task BackoffOrThrowAsync()
         {
-            var clusterMetadata = await GetClusterMetadataAsync(token);
-            if (clusterMetadata == null)
-            {
-                return null;
-            }
-
-            // Single-node cluster (clustering disabled) - no redirection needed
-            if (clusterMetadata.Nodes.Count() == 1)
-            {
-                return null;
-            }
-
-            var leaderNode = clusterMetadata.Nodes.FirstOrDefault(x => x.Role == ClusterNodeRole.Leader);
-            if (leaderNode == null)
+            if (++redirects > VsrMaxLeaderRedirects)
             {
+                SetConnectionStateAsync(ConnectionState.Disconnected);
                 throw new MissingLeaderException();
             }
 
-            return leaderNode;
+            _logger.LogInformation("Following leader redirect {Redirect} to {Address}", redirects, _currentAddress);
+
+            await Task.Delay(delay, token);
         }
-        // todo: change after error refactoring, error code 5 is for feature not supported
-        catch (IggyInvalidStatusCodeException e) when (e.StatusCode == 5)
+    }
+
+    /// <summary>
+    ///     Whether this connect was triggered by a login or register request that will re-authenticate itself,
+    ///     so the auto-login must sit this one out. Consumes the flag.
+    /// </summary>
+    private bool ConsumeSkipAutoLogin()
+    {
+        if (Interlocked.Exchange(ref _skipAutoLoginOnce, 0) == 0)
         {
-            return null;
+            return false;
         }
+
+        _logger.LogInformation("Skipping auto login for a replayed register request");
+
+        return true;
     }
 
     private async Task<TcpConnectionStream> CreateSslStreamAndAuthenticate(Socket socket, TlsSettings tlsSettings)
@@ -1104,7 +1247,7 @@ public sealed class TcpMessageStream : IIggyClient
         {
             return await SendRawAsync(payload, token);
         }
-        catch (Exception e) when (IsConnectionException(e) && !_isConnecting)
+        catch (Exception e) when (IsConnectionException(e) && !IsConnecting && !_disposed)
         {
             _logger.LogWarning("Connection lost");
             if (!_configuration.ReconnectionSettings.Enabled)
@@ -1149,13 +1292,24 @@ public sealed class TcpMessageStream : IIggyClient
         }
     }
 
-    private async Task<IMemoryOwner<byte>> SendRawAsync(ReadOnlyMemory<byte> payload, CancellationToken token)
+    private Task<IMemoryOwner<byte>> SendRawAsync(ReadOnlyMemory<byte> payload, CancellationToken token)
     {
+        ObjectDisposedException.ThrowIf(_disposed, this);
+
         if (_state is ConnectionState.Disconnected or ConnectionState.Connecting)
         {
             throw new NotConnectedException();
         }
 
+        return _isVsr ? SendRawVsrAsync(payload, token) : SendRawClassicAsync(payload, token);
+    }
+
+    /// <summary>
+    ///     Sends a classic-framed payload (<c>[size u32][code u32][body]</c>) and reads the
+    ///     <c>[status u32][length u32]</c> reply header plus its body.
+    /// </summary>
+    private async Task<IMemoryOwner<byte>> SendRawClassicAsync(ReadOnlyMemory<byte> payload, CancellationToken token)
+    {
         await _sendingSemaphore.WaitAsync(token);
 
         try
@@ -1186,7 +1340,7 @@ public sealed class TcpMessageStream : IIggyClient
                 if (response.Length == 0)
                 {
                     throw new IggyInvalidStatusCodeException(response.Status,
-                        $"Invalid response status code: {response.Status}");
+                        $"Invalid response status code: {response.Status}", true);
                 }
 
 
@@ -1357,30 +1511,6 @@ public sealed class TcpMessageStream : IIggyClient
         return false;
     }
 
-    private async Task<bool> RedirectAsync(CancellationToken token)
-    {
-        var currentLeaderNode = await GetCurrentLeaderNodeAsync(token);
-        if (currentLeaderNode == null)
-        {
-            return false;
-        }
-
-        var leaderAddress = $"{currentLeaderNode.Ip}:{currentLeaderNode.Endpoints.Tcp}";
-        if (leaderAddress == _currentAddress)
-        {
-            return false;
-        }
-
-        _currentAddress = leaderAddress;
-
-        _logger.LogInformation("Leader address changed. Trying to reconnect to {Address}",
-            leaderAddress);
-
-        _stream.Close();
-        SetConnectionStateAsync(ConnectionState.Disconnected);
-        return true;
-    }
-
     internal sealed class EmptyMemoryOwner : IMemoryOwner<byte>
     {
         public static readonly EmptyMemoryOwner Instance = new();
diff --git a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
index 776883aac..d45e42211 100644
--- a/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
+++ b/foreign/csharp/Iggy_SDK/Iggy_SDK.csproj
@@ -69,6 +69,7 @@
 
     <ItemGroup>
         <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
+        <PackageReference Include="System.IO.Hashing" />
     </ItemGroup>
 
     <ItemGroup>
diff --git a/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs b/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs
index 969118e40..cae62bfb5 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/BackgroundMessageProcessor.cs
@@ -17,6 +17,7 @@
 
 using System.Threading.Channels;
 using Apache.Iggy.Enums;
+using Apache.Iggy.Exceptions;
 using Apache.Iggy.IggyClient;
 using Apache.Iggy.Messages;
 using Apache.Iggy.Utils;
@@ -55,6 +56,11 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable
     private int _inFlight;
     private PooledBufferWriter _payloadBuffer;
 
+    // Set by DisposeAsync so the loop finishes what it has instead of being cancelled mid-send. A send cancelled
+    // after its first byte is reported as an outcome only the server knows, which would make every ordinary
+    // shutdown publish a batch that may have committed twice.
+    private int _stopping;
+
     public BackgroundMessageProcessor(IIggyClient client, IggyPublisherConfig config, ILoggerFactory loggerFactory)
     {
         _client = client;
@@ -97,7 +103,7 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable
 
         _client.UnsubscribeConnectionEvents(ClientOnOnConnectionStateChanged);
 
-        await _cancellationTokenSource.CancelAsync();
+        Volatile.Write(ref _stopping, 1);
         _writer.TryComplete();
 
         var backgroundTaskTimedOut = false;
@@ -118,9 +124,14 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable
             {
                 LogBackgroundProcessorError(e);
             }
+            finally
+            {
+                await _cancellationTokenSource.CancelAsync();
+            }
         }
         else
         {
+            await _cancellationTokenSource.CancelAsync();
             DrainAndDispose();
         }
 
@@ -244,9 +255,11 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable
         {
             while (!ct.IsCancellationRequested)
             {
+                var stopping = Volatile.Read(ref _stopping) != 0;
+
                 if (!_canSend)
                 {
-                    if (!await timer.WaitForNextTickAsync(ct))
+                    if (stopping || !await timer.WaitForNextTickAsync(ct))
                     {
                         break;
                     }
@@ -256,7 +269,7 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable
 
                 if (!AccumulateBatch())
                 {
-                    if (!await timer.WaitForNextTickAsync(ct))
+                    if (stopping || !await timer.WaitForNextTickAsync(ct))
                     {
                         break;
                     }
@@ -477,6 +490,16 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable
                 // Disposal cancellation, not a send failure; let the loop's cancellation handling take over.
                 throw;
             }
+            catch (VsrRequestOutcomeUnknownException ex)
+            {
+                // May already have committed - report it as its own type rather than folding it into the
+                // generic failure path, so a subscriber can tell "not sent" from "possibly sent twice".
+                LogFailedToSendBatch(ex, wire.Count);
+                if (_messageBatchErrorAggregator.HasSubscribers)
+                {
+                    _messageBatchErrorAggregator.Publish(new MessageBatchFailedEventArgs(ex, SnapshotForFailure(wire)));
+                }
+            }
             catch (Exception ex)
             {
                 LogFailedToSendBatch(ex, wire.Count);
@@ -507,6 +530,20 @@ internal sealed partial class BackgroundMessageProcessor : IAsyncDisposable
                 // remaining attempts instantly and publish a misleading "failed after N attempts" event.
                 throw;
             }
+            catch (VsrRequestOutcomeUnknownException ex)
+            {
+                // The send may already have committed. The partition plane is sessionless - it keeps no
+                // client-table entry to deduplicate an append against - so a retry cannot be matched to the
+                // original under any client id and would simply append the batch twice. Report it instead.
+                LogFailedToSendBatch(ex, wire.Count);
+                if (_messageBatchErrorAggregator.HasSubscribers)
+                {
+                    _messageBatchErrorAggregator.Publish(new MessageBatchFailedEventArgs(ex, SnapshotForFailure(wire),
+                        attempt + 1));
+                }
+
+                return;
+            }
             catch (Exception ex)
             {
                 lastException = ex;
diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs
index c90bedfa6..67e5a5226 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisher.cs
@@ -173,7 +173,7 @@ public partial class IggyPublisher : IAsyncDisposable
         await Client.ConnectAsync(ct);
 
         LogInitializingPublisher(Config.StreamId, Config.TopicId);
-        if (Config.CreateIggyClient)
+        if (!string.IsNullOrEmpty(Config.Login) && !Config.CreateIggyClient)
         {
             await Client.LoginUserAsync(Config.Login, Config.Password, ct);
             LogUserLoggedIn(Config.Login);
diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs
index 47fa6f09d..fcdff215e 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilder.cs
@@ -33,7 +33,7 @@ namespace Apache.Iggy.Publishers;
 /// </summary>
 public class IggyPublisherBuilder
 {
-    private IMessageEncryptor? _encryptor;
+    private protected IMessageEncryptor? _encryptor;
 
     internal Func<PublisherErrorEventArgs, Task>? OnBackgroundError { get; set; }
     internal Func<MessageBatchFailedEventArgs, Task>? OnMessageBatchFailed { get; set; }
@@ -127,6 +127,18 @@ public class IggyPublisherBuilder
         return this;
     }
 
+    /// <summary>
+    ///     Selects the wire framing the publisher's client speaks. Defaults to <see cref="WireProtocol.Classic" />.
+    /// </summary>
+    /// <param name="wireProtocol">The wire framing to use. VSR requires <see cref="Protocol.Tcp" />.</param>
+    /// <returns>The builder instance for method chaining.</returns>
+    public IggyPublisherBuilder WithWireProtocol(WireProtocol wireProtocol)
+    {
+        Config.WireProtocol = wireProtocol;
+
+        return this;
+    }
+
     /// <summary>
     ///     Configures the partitioning strategy for messages sent by the publisher.
     ///     Determines how messages are distributed across topic partitions.
@@ -301,10 +313,12 @@ public class IggyPublisherBuilder
             IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator
             {
                 Protocol = Config.Protocol,
+                WireProtocol = Config.WireProtocol,
                 BaseAddress = Config.Address,
                 ReceiveBufferSize = Config.ReceiveBufferSize,
                 SendBufferSize = Config.SendBufferSize,
                 ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
+                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
                 LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
                 MessageEncryptor = _encryptor
             });
diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs
index 962b401af..6ed571198 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherBuilderOfT.cs
@@ -88,9 +88,14 @@ public class IggyPublisherBuilder<T> : IggyPublisherBuilder
             IggyClient = IggyClientFactory.CreateClient(new IggyClientConfigurator
             {
                 Protocol = Config.Protocol,
+                WireProtocol = Config.WireProtocol,
                 BaseAddress = Config.Address,
                 ReceiveBufferSize = Config.ReceiveBufferSize,
-                SendBufferSize = Config.SendBufferSize
+                SendBufferSize = Config.SendBufferSize,
+                ReconnectionSettings = Config.ReconnectionSettings ?? new ReconnectionSettings(),
+                AutoLoginSettings = AutoLoginSettings.For(Config.Login, Config.Password),
+                LoggerFactory = Config.LoggerFactory ?? NullLoggerFactory.Instance,
+                MessageEncryptor = _encryptor
             });
         }
 
@@ -134,8 +139,7 @@ public class IggyPublisherBuilder<T> : IggyPublisherBuilder
         }
         else
         {
-            throw new InvalidOperationException(
-                $"Config must be of type IggyPublisherConfig<{typeof(T).Name}>.");
+            throw new InvalidOperationException($"Config must be of type IggyPublisherConfig<{typeof(T).Name}>.");
         }
     }
 }
diff --git a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs
index 5354069af..e80cd3a76 100644
--- a/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs
+++ b/foreign/csharp/Iggy_SDK/Publishers/IggyPublisherConfig.cs
@@ -55,6 +55,13 @@ public class IggyPublisherConfig
     /// </summary>
     public Protocol Protocol { get; set; }
 
+    /// <summary>
+    ///     The wire framing to use. Defaults to <see cref="Enums.WireProtocol.Classic" />;
+    ///     <see cref="Enums.WireProtocol.Vsr" /> requires <see cref="Enums.Protocol.Tcp" />.
+    ///     Only used when <see cref="CreateIggyClient" /> is true.
+    /// </summary>
+    public WireProtocol WireProtocol { get; set; } = WireProtocol.Classic;
+
     /// <summary>
     ///     Gets or sets the server address to connect to.
     ///     Format depends on protocol (e.g., "localhost:8090" for TCP/QUIC, "http://localhost:3000" for HTTP).
diff --git a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
index 8922d37d6..c7a3051e5 100644
--- a/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
+++ b/foreign/csharp/Iggy_SDK/Utils/CommandCodes.cs
@@ -47,6 +47,8 @@ internal static class CommandCodes
     internal const int GET_CONSUMER_OFFSET_CODE = 120;
     internal const int STORE_CONSUMER_OFFSET_CODE = 121;
     internal const int DELETE_CONSUMER_OFFSET_CODE = 122;
+    internal const int STORE_CONSUMER_OFFSET_2_CODE = 123;
+    internal const int DELETE_CONSUMER_OFFSET_2_CODE = 124;
     internal const int GET_STREAM_CODE = 200;
     internal const int GET_STREAMS_CODE = 201;
     internal const int CREATE_STREAM_CODE = 202;
@@ -68,4 +70,5 @@ internal static class CommandCodes
     internal const int DELETE_CONSUMER_GROUP_CODE = 603;
     internal const int JOIN_CONSUMER_GROUP_CODE = 604;
     internal const int LEAVE_CONSUMER_GROUP_CODE = 605;
+    internal const int SYNC_CONSUMER_GROUP_CODE = 606;
 }
diff --git a/foreign/csharp/Iggy_SDK/Utils/ServerAddress.cs b/foreign/csharp/Iggy_SDK/Utils/ServerAddress.cs
new file mode 100644
index 000000000..2f8578461
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Utils/ServerAddress.cs
@@ -0,0 +1,139 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Net;
+using System.Net.Sockets;
+
+namespace Apache.Iggy.Utils;
+
+/// <summary>
+///     Endpoint comparison for leader redirection. The configured address is whatever the caller wrote
+///     (<c>localhost:8090</c>), while the cluster roster reports IPs, so the two are compared as parsed
+///     endpoints rather than as strings.
+/// </summary>
+internal static class ServerAddress
+{
+    internal static bool IsSame(string first, string second)
+    {
+        return Normalize(first) == Normalize(second);
+    }
+
+    /// <summary>
+    ///     Renders <c>host:port</c>, bracketing a host that carries colons of its own (a bare IPv6 address).
+    ///     Without the brackets the rendering can never round-trip through <see cref="TrySplitHostPort" />, so
+    ///     it would compare unequal to every normalized address.
+    /// </summary>
+    internal static string HostPort(string host, ushort port)
+    {
+        return host.Contains(':') && !host.StartsWith('[') ? $"[{host}]:{port}" : $"{host}:{port}";
+    }
+
+    internal static bool TryParse(string address, out string host, out int port)
+    {
+        var parsed = TrySplitHostPort(address, out host, out var hostPort);
+        port = hostPort;
+
+        return parsed;
+    }
+
+    /// <summary>
+    ///     The socket family a host has to be dialled on. A name resolves to whatever the resolver returns, and
+    ///     the connect call handles that itself, so only a literal decides the family here.
+    /// </summary>
+    internal static AddressFamily AddressFamilyOf(string host)
+    {
+        return IPAddress.TryParse(host, out var ip) ? ip.AddressFamily : AddressFamily.InterNetwork;
+    }
+
+    /// <summary>
+    ///     Canonical <c>host:port</c> rendering: the host is lowercased, the loopback and unspecified aliases
+    ///     collapse onto the loopback address, and an IP is re-rendered from its parsed form. The host is
+    ///     replaced as a whole, never as a substring, so a node named <c>my-localhost-1</c> keeps its name.
+    ///     An address that is not <c>host:port</c> is only lowercased, which leaves it comparable but distinct.
+    /// </summary>
+    internal static string Normalize(string address)
+    {
+        if (!TrySplitHostPort(address, out var host, out var port))
+        {
+            return address.ToLowerInvariant();
+        }
+
+        if (!IPAddress.TryParse(NormalizeHostAlias(host), out var ip))
+        {
+            return $"{host.ToLowerInvariant()}:{port}";
+        }
+
+        return ip.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{ip}]:{port}" : $"{ip}:{port}";
+    }
+
+    /// <summary>
+    ///     A server bound to the unspecified address is reachable on the loopback one, and the roster may
+    ///     report either, so both render the same way.
+    /// </summary>
+    private static string NormalizeHostAlias(string host)
+    {
+        if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase))
+        {
+            return "127.0.0.1";
+        }
+
+        if (!IPAddress.TryParse(host, out var ip))
+        {
+            return host;
+        }
+
+        if (ip.Equals(IPAddress.Any))
+        {
+            return IPAddress.Loopback.ToString();
+        }
+
+        return ip.Equals(IPAddress.IPv6Any) ? IPAddress.IPv6Loopback.ToString() : host;
+    }
+
+    private static bool TrySplitHostPort(string address, out string host, out ushort port)
+    {
+        host = string.Empty;
+        port = 0;
+        string portText;
+
+        // A bracketed host is the only form that may carry colons of its own.
+        if (address.StartsWith('['))
+        {
+            var closing = address.IndexOf(']');
+            if (closing < 0 || closing + 1 >= address.Length || address[closing + 1] != ':')
+            {
+                return false;
+            }
+
+            host = address[1..closing];
+            portText = address[(closing + 2)..];
+        }
+        else
+        {
+            var separator = address.IndexOf(':');
+            if (separator <= 0 || address.IndexOf(':', separator + 1) >= 0)
+            {
+                return false;
+            }
+
+            host = address[..separator];
+            portText = address[(separator + 1)..];
+        }
+
+        return host.Length > 0 && ushort.TryParse(portText, out port);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs b/foreign/csharp/Iggy_SDK/Vsr/Command2.cs
similarity index 65%
copy from foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
copy to foreign/csharp/Iggy_SDK/Vsr/Command2.cs
index 6ba6e14d3..c8f7a765d 100644
--- a/foreign/csharp/Iggy_SDK/Exceptions/IggyInvalidStatusCodeException.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/Command2.cs
@@ -15,20 +15,16 @@
 // specific language governing permissions and limitations
 // under the License.
 
-namespace Apache.Iggy.Exceptions;
+namespace Apache.Iggy.Vsr;
 
 /// <summary>
-///     Exception thrown when the status code returned by the server is not valid.
+///     VSR frame discriminant, byte 60 of every consensus header. Only the frames a client emits or
+///     receives are named; every other discriminant decodes as <see cref="Reserved" />.
 /// </summary>
-public sealed class IggyInvalidStatusCodeException : Exception
+internal enum Command2 : byte
 {
-    /// <summary>
-    ///     Status code returned by the server.
-    /// </summary>
-    public int StatusCode { get; init; }
-
-    internal IggyInvalidStatusCodeException(int statusCode, string message) : base(message)
-    {
-        StatusCode = statusCode;
-    }
+    Reserved = 0,
+    Request = 5,
+    Reply = 8,
+    Eviction = 13
 }
diff --git a/foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs b/foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs
new file mode 100644
index 000000000..8cdbdcc16
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/ConsensusSession.cs
@@ -0,0 +1,223 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using System.Security.Cryptography;
+using Apache.Iggy.Exceptions;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Consensus-level session state: the ephemeral client id, the session number the server hands back
+///     when a register commits, and the monotonic request counter.
+/// </summary>
+/// <remarks>
+///     Every mutation and every read of the identity runs under one lock. The transport serialises the
+///     re-arms against the requests with its sending lock, so a request always encodes from the identity
+///     that is live for the whole time it is on the wire.
+/// </remarks>
+internal sealed class ConsensusSession
+{
+#if NET10_0_OR_GREATER
+    private readonly Lock _gate = new();
+#else
+    private readonly object _gate = new();
+#endif
+    private UInt128 _clientId;
+    private bool _registerPending;
+    private ulong _requestCounter;
+    private ulong? _session;
+
+    /// <summary>Ephemeral client identifier, never persisted, non-zero.</summary>
+    internal UInt128 ClientId
+    {
+        get
+        {
+            lock (_gate)
+            {
+                return _clientId;
+            }
+        }
+    }
+
+    /// <summary>Session fence epoch assigned by the server, <c>null</c> until a register commits.</summary>
+    internal ulong? Session
+    {
+        get
+        {
+            lock (_gate)
+            {
+                return _session;
+            }
+        }
+    }
+
+    /// <summary>The id the next request id resolution will return.</summary>
+    internal ulong RequestCounter
+    {
+        get
+        {
+            lock (_gate)
+            {
+                return _requestCounter;
+            }
+        }
+    }
+
+    internal bool IsBound
+    {
+        get
+        {
+            lock (_gate)
+            {
+                return _session.HasValue;
+            }
+        }
+    }
+
+    internal ConsensusSession() : this(GenerateClientId())
+    {
+    }
+
+    internal ConsensusSession(UInt128 clientId)
+    {
+        _clientId = clientId;
+        _requestCounter = 1;
+    }
+
+    /// <summary>
+    ///     Resolves the identity one request header is encoded from, in a single atomic step so no reset can
+    ///     interleave between the client id, the request id and the session.
+    /// </summary>
+    internal SessionFrame Resolve(VsrOperation operation)
+    {
+        lock (_gate)
+        {
+            return operation switch
+            {
+                VsrOperation.Register => RegisterFrameLocked(),
+                VsrOperation.NonReplicated => new SessionFrame(_clientId, _requestCounter, _session ?? 0),
+                _ => ReplicatedFrameLocked(operation)
+            };
+        }
+    }
+
+    /// <summary>Bind the session from a committed register reply.</summary>
+    /// <param name="session">The session number the register reply carried.</param>
+    /// <exception cref="NotConnectedException">
+    ///     No register is awaiting a binding, so the identity was re-armed while this one was in flight. Binding
+    ///     regardless would pair the session with a client id the server never registered, and it would fence
+    ///     every later request.
+    /// </exception>
+    /// <exception cref="IggyInvalidStatusCodeException">
+    ///     The register reply carried no session. The value comes off the wire, so a malformed reply has to
+    ///     surface as a protocol error rather than as argument validation.
+    /// </exception>
+    internal void Bind(ulong session)
+    {
+        if (session == 0)
+        {
+            throw VsrError.Exception(VsrError.INVALID_FORMAT, "Register reply carried no session.");
+        }
+
+        lock (_gate)
+        {
+            if (!_registerPending)
+            {
+                throw new NotConnectedException();
+            }
+
+            _registerPending = false;
+            _session = session;
+        }
+    }
+
+    /// <summary>Forget the binding and the client id, e.g. after an eviction or a torn connection.</summary>
+    internal void Reset()
+    {
+        lock (_gate)
+        {
+            ReArmLocked();
+        }
+    }
+
+    /// <summary>
+    ///     Begin a registration, re-arming the session if it was already used. A re-login mints a fresh
+    ///     client id and clears the binding, so the register encodes cleanly and the server never has to
+    ///     disambiguate a repeat register for the same client. Always returns 0.
+    /// </summary>
+    private SessionFrame RegisterFrameLocked()
+    {
+        // A second register while one is still unbound would re-arm the identity out from under the first, and
+        // the winner's Bind would then attach its session to the re-armed client id - the server answers every
+        // later request with NoSession and evicts. Refuse instead: the caller retries against a clean session.
+        if (_registerPending)
+        {
+            throw VsrError.Exception(VsrError.UNAUTHENTICATED, "A consensus register is already in flight.");
+        }
+
+        if (_session.HasValue)
+        {
+            ReArmLocked();
+        }
+
+        _registerPending = true;
+
+        return new SessionFrame(_clientId, 0, 0);
+    }
+
+    private SessionFrame ReplicatedFrameLocked(VsrOperation operation)
+    {
+        var sessionId = _session ?? throw VsrError.Exception(VsrError.UNAUTHENTICATED,
+            "A replicated request requires a bound consensus session.");
+
+        // Partition ops replicate in their own per-partition group with no client-table dedup, so they too
+        // must leave the metadata counter untouched. Only metadata operations and logout consume an id: the
+        // server tracks request ids for those alone, and it accepts any id above the client's watermark.
+        if (operation.IsPartition())
+        {
+            return new SessionFrame(_clientId, _requestCounter, sessionId);
+        }
+
+        var requestId = _requestCounter;
+        _requestCounter = checked(_requestCounter + 1);
+
+        return new SessionFrame(_clientId, requestId, sessionId);
+    }
+
+    private void ReArmLocked()
+    {
+        _clientId = GenerateClientId();
+        _session = null;
+        _requestCounter = 1;
+        _registerPending = false;
+    }
+
+    private static UInt128 GenerateClientId()
+    {
+        Span<byte> bytes = stackalloc byte[16];
+        RandomNumberGenerator.Fill(bytes);
+        var lower = BinaryPrimitives.ReadUInt64LittleEndian(bytes[..8]);
+        var upper = BinaryPrimitives.ReadUInt64LittleEndian(bytes[8..]);
+        var clientId = new UInt128(upper, lower);
+
+        return clientId == UInt128.Zero ? UInt128.One : clientId;
+    }
+}
+
+/// <summary>The session identity one request header is encoded from, resolved as a single atomic snapshot.</summary>
+internal readonly record struct SessionFrame(UInt128 ClientId, ulong RequestId, ulong SessionId);
diff --git a/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
new file mode 100644
index 000000000..e594443ae
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/ConsumerGroupClientState.cs
@@ -0,0 +1,301 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Per-connection cache of consumer-group assignments and topic partition counts, mirroring
+///     <c>core/common/src/consumer_group_client_state.rs</c>. Under VSR the broker never picks a partition, so
+///     the client resolves group polls and balanced / message-key produce locally. The cursors have to survive
+///     across calls, which is why this lives on the long-lived transport rather than on a request.
+/// </summary>
+internal sealed class ConsumerGroupClientState
+{
+    private readonly Dictionary<GroupKey, GroupAssignment> _assignments = [];
+    private readonly Dictionary<TopicKey, int> _balancedCursors = [];
+#if NET10_0_OR_GREATER
+    private readonly Lock _gate = new();
+#else
+    private readonly object _gate = new();
+#endif
+    private readonly Dictionary<GroupKey, GroupIdentifiers> _joinedGroups = [];
+    private readonly Dictionary<TopicKey, CachedPartitionCount> _partitionCounts = [];
+
+    /// <summary>Nothing tells this client when another one resizes a topic, so the count expires on its own.</summary>
+    private const long PartitionCountTtlMs = 30_000;
+
+    /// <summary>True when a non-empty assignment is cached for the group.</summary>
+    internal bool HasAssignment(GroupKey key)
+    {
+        lock (_gate)
+        {
+            return _assignments.TryGetValue(key, out var assignment) && assignment.Partitions.Count > 0;
+        }
+    }
+
+    /// <summary>
+    ///     Replaces a group's cached assignment. A generation change is a rebalance, so the round-robin cursor
+    ///     restarts rather than carrying an index that meant something else.
+    /// </summary>
+    internal void SetAssignment(GroupKey key, ulong generation, IReadOnlyList<uint> partitions)
+    {
+        lock (_gate)
+        {
+            if (!_assignments.TryGetValue(key, out var assignment))
+            {
+                assignment = new GroupAssignment();
+                _assignments[key] = assignment;
+            }
+
+            if (assignment.Generation != generation)
+            {
+                assignment.Cursor = 0;
+            }
+
+            assignment.Generation = generation;
+            assignment.Partitions = partitions;
+        }
+    }
+
+    internal void InvalidateAssignment(GroupKey key)
+    {
+        lock (_gate)
+        {
+            _assignments.Remove(key);
+        }
+    }
+
+    /// <summary>
+    ///     The next assigned partition for a group poll, advancing the cursor. <c>null</c> when nothing is cached
+    ///     or the member holds no partitions.
+    /// </summary>
+    internal uint? NextGroupPartition(GroupKey key)
+    {
+        lock (_gate)
+        {
+            if (!_assignments.TryGetValue(key, out var assignment) || assignment.Partitions.Count == 0)
+            {
+                return null;
+            }
+
+            var index = assignment.Cursor % assignment.Partitions.Count;
+            assignment.Cursor = assignment.Cursor == int.MaxValue ? 0 : assignment.Cursor + 1;
+
+            return assignment.Partitions[index];
+        }
+    }
+
+    /// <summary>The next balanced produce partition for a topic, advancing the cursor.</summary>
+    internal uint NextBalancedPartition(TopicKey key, uint partitionCount)
+    {
+        if (partitionCount == 0)
+        {
+            return 0;
+        }
+
+        lock (_gate)
+        {
+            _balancedCursors.TryGetValue(key, out var cursor);
+            var partition = (uint)(cursor % partitionCount);
+            _balancedCursors[key] = cursor == int.MaxValue ? 0 : cursor + 1;
+
+            return partition;
+        }
+    }
+
+    internal uint? PartitionCount(TopicKey key)
+    {
+        lock (_gate)
+        {
+            if (!_partitionCounts.TryGetValue(key, out var cached))
+            {
+                return null;
+            }
+
+            if (Environment.TickCount64 >= cached.ExpiresAt)
+            {
+                _partitionCounts.Remove(key);
+
+                return null;
+            }
+
+            return cached.Count;
+        }
+    }
+
+    internal void SetPartitionCount(TopicKey key, uint partitionCount)
+    {
+        lock (_gate)
+        {
+            _partitionCounts[key] = new CachedPartitionCount(partitionCount,
+                Environment.TickCount64 + PartitionCountTtlMs);
+        }
+    }
+
+    /// <summary>
+    ///     Forgets a topic's cached partition count immediately, for the changes this client makes itself.
+    ///     Changes made by anyone else are covered by <see cref="PartitionCountTtlMs" />.
+    /// </summary>
+    internal void InvalidatePartitionCount(TopicKey key)
+    {
+        lock (_gate)
+        {
+            _partitionCounts.Remove(key);
+        }
+    }
+
+    /// <summary>Records a joined group's identifiers so a later refresh can rebuild its sync request.</summary>
+    internal void RegisterGroup(GroupKey key, Identifier streamId, Identifier topicId, Identifier groupId)
+    {
+        lock (_gate)
+        {
+            _joinedGroups[key] = new GroupIdentifiers(streamId, topicId, groupId);
+        }
+    }
+
+    internal void DeregisterGroup(GroupKey key)
+    {
+        lock (_gate)
+        {
+            _joinedGroups.Remove(key);
+            _assignments.Remove(key);
+        }
+    }
+
+    /// <summary>
+    ///     True when the last assignment sync saw this client as a member. A member mid-rebalance, or one holding
+    ///     zero partitions, is still registered, so this asks a different question than
+    ///     <see cref="HasAssignment" />.
+    /// </summary>
+    internal bool IsRegistered(GroupKey key)
+    {
+        lock (_gate)
+        {
+            return _joinedGroups.ContainsKey(key);
+        }
+    }
+
+    internal IReadOnlyList<GroupIdentifiers> RegisteredGroups()
+    {
+        lock (_gate)
+        {
+            return _joinedGroups.Count == 0 ? [] : [.. _joinedGroups.Values];
+        }
+    }
+
+    /// <summary>
+    ///     Drops what a consensus session owns. The assignments are fenced by a generation the coordinator tracks
+    ///     per session, so carrying them across a reset would fence every poll, and membership has to be re-synced
+    ///     before it can be trusted again. The balanced cursors and the cached partition counts stay: they belong
+    ///     to a topic, not to a session, and clearing them restarts the produce round-robin at partition 0 and
+    ///     costs a metadata round trip per topic on every reconnect.
+    /// </summary>
+    internal void ClearSessionScoped()
+    {
+        lock (_gate)
+        {
+            _assignments.Clear();
+            _joinedGroups.Clear();
+        }
+    }
+
+    internal readonly record struct GroupIdentifiers(Identifier StreamId, Identifier TopicId, Identifier GroupId);
+
+    private readonly record struct CachedPartitionCount(uint Count, long ExpiresAt);
+
+    private sealed class GroupAssignment
+    {
+        internal IReadOnlyList<uint> Partitions { get; set; } = [];
+        internal ulong Generation { get; set; }
+        internal int Cursor { get; set; }
+    }
+}
+
+/// <summary>
+///     Cache key for a topic. The identifier kind is part of the key because a stream named "1" and the stream
+///     with id 1 are different streams that share a rendering. Identifiers are compared by their wire bytes:
+///     <see cref="Identifier" /> compares its value array by reference, and every call site builds a fresh one.
+/// </summary>
+internal readonly struct TopicKey(Identifier streamId, Identifier topicId) : IEquatable<TopicKey>
+{
+    private Identifier StreamId { get; } = streamId;
+    private Identifier TopicId { get; } = topicId;
+
+    public bool Equals(TopicKey other)
+    {
+        return IdentifierKey.Equal(StreamId, other.StreamId) && IdentifierKey.Equal(TopicId, other.TopicId);
+    }
+
+    public override bool Equals(object? obj)
+    {
+        return obj is TopicKey other && Equals(other);
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(IdentifierKey.Hash(StreamId), IdentifierKey.Hash(TopicId));
+    }
+
+    public override string ToString()
+    {
+        return $"{StreamId}|{TopicId}";
+    }
+}
+
+/// <summary>Cache key for a consumer group on a topic.</summary>
+internal readonly struct GroupKey(Identifier streamId, Identifier topicId, Identifier groupId) : IEquatable<GroupKey>
+{
+    private TopicKey Topic { get; } = new(streamId, topicId);
+    private Identifier GroupId { get; } = groupId;
+
+    public bool Equals(GroupKey other)
+    {
+        return Topic.Equals(other.Topic) && IdentifierKey.Equal(GroupId, other.GroupId);
+    }
+
+    public override bool Equals(object? obj)
+    {
+        return obj is GroupKey other && Equals(other);
+    }
+
+    public override int GetHashCode()
+    {
+        return HashCode.Combine(Topic.GetHashCode(), IdentifierKey.Hash(GroupId));
+    }
+
+    public override string ToString()
+    {
+        return $"{Topic}|{GroupId}";
+    }
+}
+
+internal static class IdentifierKey
+{
+    internal static bool Equal(Identifier first, Identifier second)
+    {
+        return first.Kind == second.Kind && first.Value.AsSpan().SequenceEqual(second.Value);
+    }
+
+    internal static int Hash(Identifier identifier)
+    {
+        var hash = new HashCode();
+        hash.Add((byte)identifier.Kind);
+        hash.AddBytes(identifier.Value);
+
+        return hash.ToHashCode();
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/CredentialBounds.cs b/foreign/csharp/Iggy_SDK/Vsr/CredentialBounds.cs
new file mode 100644
index 000000000..11fde1187
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/CredentialBounds.cs
@@ -0,0 +1,66 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Text;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     The credential bounds every server enforces, checked before encoding so an oversized credential is
+///     reported as the typed status code instead of desyncing the u8 length prefix on the wire. Mirrors
+///     <c>core/common/src/http/users/defaults.rs</c>.
+/// </summary>
+internal static class CredentialBounds
+{
+    internal const int MIN_USERNAME_LENGTH = 3;
+    internal const int MAX_USERNAME_LENGTH = 50;
+    internal const int MIN_PASSWORD_LENGTH = 3;
+    internal const int MAX_PASSWORD_LENGTH = 100;
+
+    internal const int MIN_TOKEN_LENGTH = 1;
+    internal const int MAX_TOKEN_LENGTH = 255;
+
+    internal static void ValidateUsername(string username)
+    {
+        var length = Encoding.UTF8.GetByteCount(username);
+        if (length is < MIN_USERNAME_LENGTH or > MAX_USERNAME_LENGTH)
+        {
+            throw VsrError.Exception(VsrError.INVALID_USERNAME,
+                $"Username must be {MIN_USERNAME_LENGTH}-{MAX_USERNAME_LENGTH} bytes, got {length}.");
+        }
+    }
+
+    internal static void ValidatePassword(string password)
+    {
+        var length = Encoding.UTF8.GetByteCount(password);
+        if (length is < MIN_PASSWORD_LENGTH or > MAX_PASSWORD_LENGTH)
+        {
+            throw VsrError.Exception(VsrError.INVALID_PASSWORD,
+                $"Password must be {MIN_PASSWORD_LENGTH}-{MAX_PASSWORD_LENGTH} bytes, got {length}.");
+        }
+    }
+
+    internal static void ValidateToken(string token)
+    {
+        var length = Encoding.UTF8.GetByteCount(token);
+        if (length is < MIN_TOKEN_LENGTH or > MAX_TOKEN_LENGTH)
+        {
+            throw VsrError.Exception(VsrError.INVALID_PERSONAL_ACCESS_TOKEN,
+                $"Personal access token must be {MIN_TOKEN_LENGTH}-{MAX_TOKEN_LENGTH} bytes, got {length}.");
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs b/foreign/csharp/Iggy_SDK/Vsr/EvictionReason.cs
similarity index 55%
copy from foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
copy to foreign/csharp/Iggy_SDK/Vsr/EvictionReason.cs
index b1b434f56..e51deeea0 100644
--- a/foreign/csharp/Iggy_SDK/Configuration/AutoLoginSettings.cs
+++ b/foreign/csharp/Iggy_SDK/Vsr/EvictionReason.cs
@@ -15,25 +15,28 @@
 // specific language governing permissions and limitations
 // under the License.
 
-namespace Apache.Iggy.Configuration;
+namespace Apache.Iggy.Vsr;
 
 /// <summary>
-///     Represents the settings required for automatic login functionality in the Iggy client.
+///     Reason carried at byte 255 of an eviction frame. Session-terminal, never transient.
+///     Discriminants are wire-pinned; a value outside this set decodes as <see cref="Reserved" />.
 /// </summary>
-public class AutoLoginSettings
+internal enum EvictionReason : byte
 {
-    /// <summary>
-    ///     Enable automatic login on connection establishment
-    /// </summary>
-    public bool Enabled { get; set; }
-
-    /// <summary>
-    ///     Specifies the username for auto-login configuration.
-    /// </summary>
-    public string Username { get; set; } = string.Empty;
-
-    /// <summary>
-    ///     Specifies the password for auto-login authentication
-    /// </summary>
-    public string Password { get; set; } = string.Empty;
+    Reserved = 0,
+    NoSession = 1,
+    ClientReleaseTooLow = 2,
+    ClientReleaseTooHigh = 3,
+    InvalidRequestOperation = 4,
+    InvalidRequestBody = 5,
+    InvalidRequestBodySize = 6,
+    SessionTooLow = 7,
+    SessionReleaseMismatch = 8,
+    InvalidCredentials = 9,
+    InvalidToken = 10,
+    UserInactive = 11,
+    SessionError = 12,
+    StaleClient = 13,
+    IncompatibleProtocol = 14,
+    MalformedLogin = 15
 }
diff --git a/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs b/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs
new file mode 100644
index 000000000..2081c6c9c
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/LoginRegister.cs
@@ -0,0 +1,168 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using System.Text;
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     The register handshake bodies. VSR replaces the legacy login codes with
+///     <c>LOGIN_REGISTER</c> / <c>LOGIN_REGISTER_WITH_PAT</c>, whose bodies lead with the client version
+///     info the server gates on before it touches credentials.
+/// </summary>
+internal static class LoginRegister
+{
+    internal const string SDK_NAME = "csharp-sdk";
+
+    /// <summary>Semver of the <c>iggy_binary_protocol</c> crate this SDK is built against.</summary>
+    internal const int PROTOCOL_VERSION_MAJOR = 0;
+
+    internal const int PROTOCOL_VERSION_MINOR = 10;
+    internal const int PROTOCOL_VERSION_PATCH = 3;
+
+    /// <summary>Packed protocol version: <c>major &lt;&lt; 20 | minor &lt;&lt; 10 | patch</c>, 10 bits each.</summary>
+    internal const uint PROTOCOL_VERSION =
+        ((uint)PROTOCOL_VERSION_MAJOR << 20) | ((uint)PROTOCOL_VERSION_MINOR << 10) | PROTOCOL_VERSION_PATCH;
+
+    private const int MaxWireNameLength = 255;
+
+    private static int VersionInfoLength => 4 + NameLength(SDK_NAME) + NameLength(SdkVersion.Value);
+
+    internal static byte[] Serialize(string username, string password, string? clientContext = null)
+    {
+        CredentialBounds.ValidateUsername(username);
+        CredentialBounds.ValidatePassword(password);
+
+        var writer = new BodyWriter(VersionInfoLength + NameLength(username) + NameLength(password) + 4 +
+                                    ContextLength(clientContext));
+        writer.WriteVersionInfo();
+        writer.WriteName(username, nameof(username));
+        writer.WriteName(password, nameof(password));
+        writer.WriteContext(clientContext);
+
+        return writer.Buffer;
+    }
+
+    internal static byte[] SerializeWithPersonalAccessToken(string token, string? clientContext = null)
+    {
+        CredentialBounds.ValidateToken(token);
+
+        var writer = new BodyWriter(VersionInfoLength + NameLength(token) + 4 + ContextLength(clientContext));
+        writer.WriteVersionInfo();
+        writer.WriteName(token, nameof(token));
+        writer.WriteContext(clientContext);
+
+        return writer.Buffer;
+    }
+
+    /// <summary>
+    ///     <c>[user_id u32][session u64][server_protocol_version u32][server_version len u8 + bytes]</c>.
+    /// </summary>
+    internal static LoginRegisterResponse Deserialize(ReadOnlySpan<byte> body)
+    {
+        if (body.IsEmpty)
+        {
+            // The server fast-fails a terminal register failure (invalid credentials, invalid token,
+            // inactive user) with an empty reply instead of a typed error frame, and the reason is not
+            // recoverable from the wire. Same INVALID_FORMAT surface as the Rust SDK.
+            throw VsrError.Exception(VsrError.INVALID_FORMAT,
+                "Server rejected the login. The register reply is empty, which the server sends for invalid " +
+                "credentials, an invalid personal access token, or an inactive user.");
+        }
+
+        if (body.Length < 17)
+        {
+            throw VsrError.Exception(VsrError.INVALID_FORMAT, "Register reply is truncated.");
+        }
+
+        var serverVersionLength = body[16];
+        if (serverVersionLength == 0 || body.Length < 17 + serverVersionLength)
+        {
+            throw VsrError.Exception(VsrError.INVALID_FORMAT, "Register reply carries a malformed server version.");
+        }
+
+        return new LoginRegisterResponse(BinaryPrimitives.ReadUInt32LittleEndian(body[..4]),
+            BinaryPrimitives.ReadUInt64LittleEndian(body[4..12]),
+            BinaryPrimitives.ReadUInt32LittleEndian(body[12..16]),
+            Encoding.UTF8.GetString(body.Slice(17, serverVersionLength)));
+    }
+
+    private static int NameLength(string value)
+    {
+        return 1 + Encoding.UTF8.GetByteCount(value);
+    }
+
+    private static int ContextLength(string? clientContext)
+    {
+        return clientContext is null ? 0 : Encoding.UTF8.GetByteCount(clientContext);
+    }
+
+    private struct BodyWriter
+    {
+        private int _position;
+
+        internal BodyWriter(int length)
+        {
+            Buffer = new byte[length];
+            _position = 0;
+        }
+
+        internal byte[] Buffer { get; }
+
+        internal void WriteVersionInfo()
+        {
+            BinaryPrimitives.WriteUInt32LittleEndian(Buffer.AsSpan(_position, 4), PROTOCOL_VERSION);
+            _position += 4;
+            WriteName(SDK_NAME, nameof(SDK_NAME));
+            WriteName(SdkVersion.Value, nameof(SdkVersion));
+        }
+
+        internal void WriteName(string value, string name)
+        {
+            var length = Encoding.UTF8.GetByteCount(value);
+            if (length is 0 or > MaxWireNameLength)
+            {
+                throw new ArgumentException($"{name} must be 1-{MaxWireNameLength} UTF-8 bytes, got {length}.", name);
+            }
+
+            Buffer[_position] = (byte)length;
+            _position += 1;
+            Encoding.UTF8.GetBytes(value, Buffer.AsSpan(_position, length));
+            _position += length;
+        }
+
+        internal void WriteContext(string? clientContext)
+        {
+            var length = ContextLength(clientContext);
+            BinaryPrimitives.WriteUInt32LittleEndian(Buffer.AsSpan(_position, 4), (uint)length);
+            _position += 4;
+            if (clientContext is not null && length > 0)
+            {
+                Encoding.UTF8.GetBytes(clientContext, Buffer.AsSpan(_position, length));
+                _position += length;
+            }
+        }
+    }
+}
+
+internal readonly record struct LoginRegisterResponse(
+    uint UserId,
+    ulong Session,
+    uint ServerProtocolVersion,
+    string ServerVersion);
diff --git a/foreign/csharp/Iggy_SDK/Vsr/SyncConsumerGroup.cs b/foreign/csharp/Iggy_SDK/Vsr/SyncConsumerGroup.cs
new file mode 100644
index 000000000..5bc8d55fb
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/SyncConsumerGroup.cs
@@ -0,0 +1,61 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Reply body of a <c>SyncConsumerGroup</c> request: the requesting member's current partition assignment
+///     and the generation it belongs to. Mirrors
+///     <c>core/binary_protocol/src/responses/consumer_groups/sync_consumer_group.rs</c>.
+/// </summary>
+/// <remarks>
+///     Wire format: <c>[generation u64][partitions_count u32][partition_id u32]*</c>. The request body is the
+///     plain <c>[stream][topic][group]</c> identifier triple every other group request already builds.
+/// </remarks>
+internal readonly record struct SyncConsumerGroupAssignment(ulong Generation, IReadOnlyList<uint> Partitions)
+{
+    internal static SyncConsumerGroupAssignment Decode(ReadOnlySpan<byte> body)
+    {
+        if (body.Length < 12)
+        {
+            throw Malformed();
+        }
+
+        var generation = BinaryPrimitives.ReadUInt64LittleEndian(body[..8]);
+        var count = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4));
+        if (body.Length < 12 + (long)count * 4)
+        {
+            throw Malformed();
+        }
+
+        var partitions = new uint[count];
+        for (var i = 0; i < partitions.Length; i++)
+        {
+            partitions[i] = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(12 + i * 4, 4));
+        }
+
+        return new SyncConsumerGroupAssignment(generation, partitions);
+    }
+
+    private static Exception Malformed()
+    {
+        return VsrError.Exception(VsrError.INVALID_COMMAND,
+            "Consumer group assignment reply is too short for the partition count it declares.");
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs
new file mode 100644
index 000000000..07e5306a8
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrError.cs
@@ -0,0 +1,61 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Exceptions;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Server error codes the VSR paths raise locally or surface from the wire. Values match
+///     <c>core/common/src/error/iggy_error.rs</c>; the server shares this code space across the reply
+///     status word, the committed result section and the eviction mapping.
+/// </summary>
+internal static class VsrError
+{
+    internal const int INVALID_COMMAND = 3;
+    internal const int INVALID_FORMAT = 4;
+    internal const int FEATURE_UNAVAILABLE = 5;
+    internal const int INVALID_IDENTIFIER = 6;
+    internal const int STALE_CLIENT = 30;
+    internal const int UNAUTHENTICATED = 40;
+    internal const int INVALID_CREDENTIALS = 42;
+    internal const int INVALID_USERNAME = 43;
+    internal const int INVALID_PASSWORD = 44;
+    internal const int INVALID_PERSONAL_ACCESS_TOKEN = 53;
+    internal const int TRANSIENT_NOT_COMMITTED = 57;
+    internal const int TRANSIENT_NOT_ACCEPTED = 58;
+    internal const int EMPTY_RESPONSE = 304;
+    internal const int TOPIC_ID_NOT_FOUND = 2010;
+    internal const int CONSUMER_GROUP_MEMBER_NOT_FOUND = 5006;
+    internal const int CONSUMER_GROUP_PARTITION_NOT_OWNED = 5009;
+    internal const int INCOMPATIBLE_PROTOCOL_VERSION = 14003;
+
+    /// <summary>A failure the client raised itself, before or instead of a server verdict.</summary>
+    internal static IggyInvalidStatusCodeException Exception(int code, string message)
+    {
+        return new IggyInvalidStatusCodeException(code, message);
+    }
+
+    /// <summary>
+    ///     A verdict the server reported, on the reply status word, in the committed result section or in an
+    ///     eviction frame. Only these may drive retry and failover.
+    /// </summary>
+    internal static IggyInvalidStatusCodeException FromServer(int code, string message)
+    {
+        return new IggyInvalidStatusCodeException(code, message, true);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs
new file mode 100644
index 000000000..5ebb96e83
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrHeader.cs
@@ -0,0 +1,156 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     The 256-byte consensus header, read and written by wire offset. Offsets mirror
+///     <c>core/binary_protocol/src/consensus/header.rs</c>. Checksums stay zero: the server does not verify
+///     them for client frames.
+/// </summary>
+internal static class VsrHeader
+{
+    internal const int HEADER_SIZE = 256;
+
+    internal const int SIZE_OFFSET = 48;
+    internal const int COMMAND_OFFSET = 60;
+
+    internal const int REQUEST_CLIENT_OFFSET = 128;
+    internal const int REQUEST_TIMESTAMP_OFFSET = 160;
+    internal const int REQUEST_ID_OFFSET = 168;
+    internal const int REQUEST_OPERATION_OFFSET = 176;
+    internal const int REQUEST_NAMESPACE_OFFSET = 184;
+    internal const int REQUEST_SESSION_OFFSET = 192;
+    internal const int REQUEST_RESERVED_OFFSET = 204;
+
+    internal const int REPLY_OPERATION_OFFSET = 208;
+    internal const int REPLY_NAMESPACE_OFFSET = 216;
+    internal const int REPLY_STATUS_OFFSET = 224;
+
+    internal const int EVICTION_CLIENT_OFFSET = 128;
+    internal const int EVICTION_PROTOCOL_VERSION_OFFSET = 144;
+    internal const int EVICTION_PROTOCOL_VERSION_MIN_OFFSET = 148;
+    internal const int EVICTION_REASON_OFFSET = 255;
+
+    /// <summary>
+    ///     Encodes the request header for a classic command code and its body. Returns the total frame size
+    ///     (header plus body).
+    /// </summary>
+    /// <remarks>
+    ///     Everything that can fail runs before a request id is consumed. The primary accepts any id above the
+    ///     watermark, so a gap costs nothing, but a consumed id can never be handed back: re-encoding it for a
+    ///     different request would let the client table answer that request from the first one's cached reply.
+    /// </remarks>
+    internal static int EncodeRequestHeader(Span<byte> header, ConsensusSession session, int code,
+        ReadOnlySpan<byte> payload)
+    {
+        if (header.Length < HEADER_SIZE)
+        {
+            throw new ArgumentException($"Header buffer must be at least {HEADER_SIZE} bytes.", nameof(header));
+        }
+
+        header = header[..HEADER_SIZE];
+        header.Clear();
+
+        var operation = VsrOperations.ForCode(code);
+        var ns = VsrNamespace.ForRequest(code, payload, operation);
+
+        if (payload.Length > int.MaxValue - HEADER_SIZE)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "Request body exceeds the maximum frame size.");
+        }
+
+        var frame = session.Resolve(operation);
+        var totalSize = HEADER_SIZE + payload.Length;
+
+        BinaryPrimitives.WriteUInt32LittleEndian(header[SIZE_OFFSET..], (uint)totalSize);
+        header[COMMAND_OFFSET] = (byte)Command2.Request;
+        WriteUInt128(header[REQUEST_CLIENT_OFFSET..], frame.ClientId);
+        BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_TIMESTAMP_OFFSET..], 0);
+        BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_ID_OFFSET..], frame.RequestId);
+        header[REQUEST_OPERATION_OFFSET] = (byte)operation;
+        BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_NAMESPACE_OFFSET..], ns);
+        BinaryPrimitives.WriteUInt64LittleEndian(header[REQUEST_SESSION_OFFSET..], frame.SessionId);
+
+        if (operation == VsrOperation.NonReplicated)
+        {
+            BinaryPrimitives.WriteUInt32LittleEndian(header[REQUEST_RESERVED_OFFSET..], (uint)code);
+        }
+
+        return totalSize;
+    }
+
+    internal static Command2 PeekCommand(ReadOnlySpan<byte> header)
+    {
+        return header[COMMAND_OFFSET] switch
+        {
+            (byte)Command2.Reply => Command2.Reply,
+            (byte)Command2.Eviction => Command2.Eviction,
+            _ => Command2.Reserved
+        };
+    }
+
+    /// <summary>Total frame size (header plus body) the peer announced.</summary>
+    internal static uint ReadSize(ReadOnlySpan<byte> header)
+    {
+        return BinaryPrimitives.ReadUInt32LittleEndian(header[SIZE_OFFSET..]);
+    }
+
+    /// <summary>
+    ///     Pre-commit deny channel. Nonzero means refused before commit with an empty body; a committed
+    ///     rejection stamps 0 here and rides the result section instead.
+    /// </summary>
+    internal static uint ReadStatus(ReadOnlySpan<byte> header)
+    {
+        return BinaryPrimitives.ReadUInt32LittleEndian(header[REPLY_STATUS_OFFSET..]);
+    }
+
+    internal static VsrOperation ReadReplyOperation(ReadOnlySpan<byte> header)
+    {
+        var operation = header[REPLY_OPERATION_OFFSET];
+        if (!VsrOperations.IsKnown(operation))
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, $"Reply carries an unknown operation ({operation}).");
+        }
+
+        return (VsrOperation)operation;
+    }
+
+    internal static EvictionFrame ReadEviction(ReadOnlySpan<byte> header)
+    {
+        var reason = header[EVICTION_REASON_OFFSET];
+
+        return new EvictionFrame(
+            reason is > 0 and <= (byte)EvictionReason.MalformedLogin ? (EvictionReason)reason : null,
+            BinaryPrimitives.ReadUInt32LittleEndian(header[EVICTION_PROTOCOL_VERSION_OFFSET..]),
+            BinaryPrimitives.ReadUInt32LittleEndian(header[EVICTION_PROTOCOL_VERSION_MIN_OFFSET..]));
+    }
+
+    private static void WriteUInt128(Span<byte> destination, UInt128 value)
+    {
+        BinaryPrimitives.WriteUInt64LittleEndian(destination, (ulong)value);
+        BinaryPrimitives.WriteUInt64LittleEndian(destination[8..], (ulong)(value >> 64));
+    }
+}
+
+/// <summary>Session-terminal eviction frame. Version fields are zero unless the reason is a protocol mismatch.</summary>
+internal readonly record struct EvictionFrame(
+    EvictionReason? Reason,
+    uint ServerProtocolVersion,
+    uint ServerProtocolVersionMin);
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrNamespace.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrNamespace.cs
new file mode 100644
index 000000000..34187591d
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrNamespace.cs
@@ -0,0 +1,251 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Packs a stream/topic/partition triple into the <c>RequestHeader.namespace</c> word the server hashes
+///     to a shard. Mirrors <c>core/binary_protocol/src/namespace.rs</c>; any drift routes writes to the
+///     wrong shard.
+/// </summary>
+internal static class VsrNamespace
+{
+    internal const int MAX_STREAMS = 4096;
+    internal const int MAX_TOPICS = 4096;
+    internal const int MAX_PARTITIONS = 1_000_000;
+
+    internal const int PARTITION_BITS = 20;
+    internal const int TOPIC_BITS = 12;
+    internal const int STREAM_BITS = 12;
+
+    internal const int PARTITION_SHIFT = 0;
+    internal const int TOPIC_SHIFT = PARTITION_SHIFT + PARTITION_BITS;
+    internal const int STREAM_SHIFT = TOPIC_SHIFT + TOPIC_BITS;
+
+    internal const ulong PARTITION_MASK = (1UL << PARTITION_BITS) - 1;
+    internal const ulong TOPIC_MASK = (1UL << TOPIC_BITS) - 1;
+    internal const ulong STREAM_MASK = (1UL << STREAM_BITS) - 1;
+
+    /// <summary>
+    ///     Reserved value routing a request to the cluster's single metadata consensus group. Sits above the
+    ///     packed layout, so it can never collide with a packed namespace.
+    /// </summary>
+    internal const ulong METADATA_CONSENSUS_NAMESPACE = 1UL << 63;
+
+    private const byte NumericIdKind = 1;
+    private const byte StringIdKind = 2;
+    private const byte ConsumerKind = 1;
+    private const byte ConsumerGroupKind = 2;
+
+    /// <summary>
+    ///     The namespace a request header must carry, peeking into the classic payload for the ops the server
+    ///     routes by partition. Named (string) stream or topic identifiers resolve to 0; the server resolves
+    ///     them itself.
+    /// </summary>
+    internal static ulong ForRequest(int code, ReadOnlySpan<byte> payload, VsrOperation operation)
+    {
+        if (operation is VsrOperation.Register or VsrOperation.Logout)
+        {
+            return METADATA_CONSENSUS_NAMESPACE;
+        }
+
+        if (operation == VsrOperation.NonReplicated || operation.IsMetadata())
+        {
+            return 0;
+        }
+
+        return code switch
+        {
+            CommandCodes.SEND_MESSAGES_CODE => FromSendMessages(payload),
+            CommandCodes.STORE_CONSUMER_OFFSET_CODE or CommandCodes.DELETE_CONSUMER_OFFSET_CODE
+                or CommandCodes.STORE_CONSUMER_OFFSET_2_CODE or CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE =>
+                FromConsumerOffset(payload),
+            CommandCodes.DELETE_SEGMENTS_CODE => FromDeleteSegments(payload),
+            _ => throw VsrError.Exception(VsrError.FEATURE_UNAVAILABLE,
+                $"Command code {code} cannot be routed to a partition namespace.")
+        };
+    }
+
+    internal static ulong Pack(uint streamId, uint topicId, uint partitionId)
+    {
+        return ((streamId & STREAM_MASK) << STREAM_SHIFT)
+               | ((topicId & TOPIC_MASK) << TOPIC_SHIFT)
+               | ((partitionId & PARTITION_MASK) << PARTITION_SHIFT);
+    }
+
+    /// <summary>
+    ///     <c>[metadata_len u32][stream][topic][partitioning][messages_count u32]</c>. Only the metadata
+    ///     prefix is peeked; the message batch that follows is never parsed.
+    /// </summary>
+    private static ulong FromSendMessages(ReadOnlySpan<byte> payload)
+    {
+        var metadataLength = (int)ReadUInt32(payload, 0);
+        if (metadataLength < 0 || payload.Length - 4 < metadataLength)
+        {
+            throw Malformed();
+        }
+
+        ReadOnlySpan<byte> metadata = payload.Slice(4, metadataLength);
+        var position = 0;
+        var streamId = ReadIdentifier(metadata, ref position);
+        var topicId = ReadIdentifier(metadata, ref position);
+
+        var kind = ReadByte(metadata, position);
+        var length = ReadByte(metadata, position + 1);
+        position += 2;
+        if (kind != (byte)Partitioning.PartitionId)
+        {
+            throw VsrError.Exception(VsrError.FEATURE_UNAVAILABLE,
+                "Under VSR the partition must be resolved by the client; balanced and message-key partitioning cannot be sent on the wire.");
+        }
+
+        if (length != 4 || metadata.Length < position + 4)
+        {
+            throw Malformed();
+        }
+
+        return FromPartition(streamId, topicId, ReadUInt32(metadata, position));
+    }
+
+    /// <summary>
+    ///     <c>[consumer kind u8][consumer id][stream][topic][partition flag u8][partition u32]</c>, shared by
+    ///     the store and delete consumer-offset requests and their v2 variants.
+    /// </summary>
+    private static ulong FromConsumerOffset(ReadOnlySpan<byte> payload)
+    {
+        var consumerKind = ReadByte(payload, 0);
+        if (consumerKind is not (ConsumerKind or ConsumerGroupKind))
+        {
+            throw Malformed();
+        }
+
+        var position = 1;
+        _ = ReadIdentifier(payload, ref position);
+        var streamId = ReadIdentifier(payload, ref position);
+        var topicId = ReadIdentifier(payload, ref position);
+
+        var flag = ReadByte(payload, position);
+        position += 1;
+
+        if (flag == 0)
+        {
+            throw VsrError.Exception(VsrError.INVALID_IDENTIFIER,
+                "Under VSR a consumer-offset request must carry an explicit partition id.");
+        }
+
+        if (payload.Length < position + 4)
+        {
+            throw Malformed();
+        }
+
+        return FromPartition(streamId, topicId, ReadUInt32(payload, position));
+    }
+
+    /// <summary>
+    ///     <c>[stream][topic][partition u32][segments_count u32]</c>.
+    /// </summary>
+    private static ulong FromDeleteSegments(ReadOnlySpan<byte> payload)
+    {
+        var position = 0;
+        var streamId = ReadIdentifier(payload, ref position);
+        var topicId = ReadIdentifier(payload, ref position);
+        if (payload.Length < position + 4)
+        {
+            throw Malformed();
+        }
+
+        return FromPartition(streamId, topicId, ReadUInt32(payload, position));
+    }
+
+    private static ulong FromPartition(uint? streamId, uint? topicId, uint partitionId)
+    {
+        if (streamId is null || topicId is null)
+        {
+            return 0;
+        }
+
+        Validate(streamId.Value, MAX_STREAMS);
+        Validate(topicId.Value, MAX_TOPICS);
+        Validate(partitionId, MAX_PARTITIONS);
+
+        return Pack(streamId.Value, topicId.Value, partitionId);
+    }
+
+    private static void Validate(uint value, int exclusiveMax)
+    {
+        if (value >= exclusiveMax)
+        {
+            throw VsrError.Exception(VsrError.INVALID_IDENTIFIER,
+                $"Identifier {value} is outside the packable namespace range (max {exclusiveMax - 1}).");
+        }
+    }
+
+    /// <summary>
+    ///     Reads an <c>[kind u8][len u8][value]</c> identifier, advancing <paramref name="position" />.
+    ///     Returns the numeric value, or <c>null</c> for a named identifier the server has to resolve.
+    ///     A kind or width the wire format does not define is corruption, not a named identifier: falling
+    ///     back to <c>null</c> would route the request to namespace 0 instead of failing it.
+    /// </summary>
+    private static uint? ReadIdentifier(ReadOnlySpan<byte> payload, ref int position)
+    {
+        var kind = ReadByte(payload, position);
+        var length = ReadByte(payload, position + 1);
+        if (payload.Length < position + 2 + length)
+        {
+            throw Malformed();
+        }
+
+        uint? value = (kind, length) switch
+        {
+            (NumericIdKind, 4) => ReadUInt32(payload, position + 2),
+            (StringIdKind, > 0) => null,
+            _ => throw Malformed()
+        };
+        position += 2 + length;
+
+        return value;
+    }
+
+    private static byte ReadByte(ReadOnlySpan<byte> payload, int offset)
+    {
+        if (payload.Length <= offset)
+        {
+            throw Malformed();
+        }
+
+        return payload[offset];
+    }
+
+    private static uint ReadUInt32(ReadOnlySpan<byte> payload, int offset)
+    {
+        if (payload.Length < offset + 4)
+        {
+            throw Malformed();
+        }
+
+        return BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(offset, 4));
+    }
+
+    private static Exception Malformed()
+    {
+        return VsrError.Exception(VsrError.INVALID_COMMAND, "Request payload is too short to resolve a namespace.");
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs
new file mode 100644
index 000000000..246278bb1
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrOperation.cs
@@ -0,0 +1,202 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Replicated operation discriminant, byte 176 of a request header and byte 208 of a reply header.
+///     Mirrors <c>core/binary_protocol/src/consensus/operation.rs</c>; discriminants are wire-pinned.
+/// </summary>
+internal enum VsrOperation : byte
+{
+    Reserved = 0,
+    Register = 1,
+    NonReplicated = 2,
+    Logout = 3,
+
+    CreateTopicWithAssignments = 64,
+    CreatePartitionsWithAssignments = 65,
+    RemoveConsumerGroupMember = 66,
+    CompleteConsumerGroupRevocation = 67,
+    TruncatePartition = 68,
+
+    CreateStream = 128,
+    UpdateStream = 129,
+    DeleteStream = 130,
+    PurgeStream = 131,
+    CreateTopic = 132,
+    UpdateTopic = 133,
+    DeleteTopic = 134,
+    PurgeTopic = 135,
+    CreatePartitions = 136,
+    DeletePartitions = 137,
+    DeleteSegments = 138,
+    CreateConsumerGroup = 139,
+    DeleteConsumerGroup = 140,
+    CreateUser = 141,
+    UpdateUser = 142,
+    DeleteUser = 143,
+    ChangePassword = 144,
+    UpdatePermissions = 145,
+    CreatePersonalAccessToken = 146,
+    DeletePersonalAccessToken = 147,
+    JoinConsumerGroup = 148,
+    LeaveConsumerGroup = 149,
+
+    SendMessages = 160,
+    StoreConsumerOffset = 161,
+    DeleteConsumerOffset = 162,
+    StoreConsumerOffset2 = 164,
+    DeleteConsumerOffset2 = 165
+}
+
+internal static class VsrOperations
+{
+    private const byte InternalStart = (byte)VsrOperation.CreateTopicWithAssignments;
+    private const byte MetadataStart = (byte)VsrOperation.CreateStream;
+    private const byte PartitionStart = (byte)VsrOperation.SendMessages;
+
+    /// <summary>
+    ///     Maps a legacy command code to the operation its request header carries. An unmapped code rides
+    ///     <see cref="VsrOperation.NonReplicated" />: the command table is a protocol registry, not a
+    ///     per-server capability list, so the server is the authority on codes this SDK build does not know.
+    /// </summary>
+    internal static VsrOperation ForCode(int code)
+    {
+        return code switch
+        {
+            CommandCodes.LOGIN_REGISTER_CODE or CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE => VsrOperation.Register,
+
+            // VSR replaces the legacy login codes with the register handshake. A legacy login code arriving here
+            // means a caller bypassed the typed path, and sending it non-replicated would look like a working
+            // login while no session is ever bound.
+            CommandCodes.LOGIN_USER_CODE or CommandCodes.LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE =>
+                throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                    $"Command {code} cannot be sent as a consensus request."),
+            CommandCodes.LOGOUT_USER_CODE => VsrOperation.Logout,
+            CommandCodes.CREATE_USER_CODE => VsrOperation.CreateUser,
+            CommandCodes.DELETE_USER_CODE => VsrOperation.DeleteUser,
+            CommandCodes.UPDATE_USER_CODE => VsrOperation.UpdateUser,
+            CommandCodes.UPDATE_PERMISSIONS_CODE => VsrOperation.UpdatePermissions,
+            CommandCodes.CHANGE_PASSWORD_CODE => VsrOperation.ChangePassword,
+            CommandCodes.CREATE_PERSONAL_ACCESS_TOKEN_CODE => VsrOperation.CreatePersonalAccessToken,
+            CommandCodes.DELETE_PERSONAL_ACCESS_TOKEN_CODE => VsrOperation.DeletePersonalAccessToken,
+            CommandCodes.SEND_MESSAGES_CODE => VsrOperation.SendMessages,
+            CommandCodes.STORE_CONSUMER_OFFSET_CODE => VsrOperation.StoreConsumerOffset,
+            CommandCodes.DELETE_CONSUMER_OFFSET_CODE => VsrOperation.DeleteConsumerOffset,
+            CommandCodes.STORE_CONSUMER_OFFSET_2_CODE => VsrOperation.StoreConsumerOffset2,
+            CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE => VsrOperation.DeleteConsumerOffset2,
+            CommandCodes.CREATE_STREAM_CODE => VsrOperation.CreateStream,
+            CommandCodes.DELETE_STREAM_CODE => VsrOperation.DeleteStream,
+            CommandCodes.UPDATE_STREAM_CODE => VsrOperation.UpdateStream,
+            CommandCodes.PURGE_STREAM_CODE => VsrOperation.PurgeStream,
+            CommandCodes.CREATE_TOPIC_CODE => VsrOperation.CreateTopic,
+            CommandCodes.DELETE_TOPIC_CODE => VsrOperation.DeleteTopic,
+            CommandCodes.UPDATE_TOPIC_CODE => VsrOperation.UpdateTopic,
+            CommandCodes.PURGE_TOPIC_CODE => VsrOperation.PurgeTopic,
+            CommandCodes.CREATE_PARTITIONS_CODE => VsrOperation.CreatePartitions,
+            CommandCodes.DELETE_PARTITIONS_CODE => VsrOperation.DeletePartitions,
+            CommandCodes.DELETE_SEGMENTS_CODE => VsrOperation.DeleteSegments,
+            CommandCodes.CREATE_CONSUMER_GROUP_CODE => VsrOperation.CreateConsumerGroup,
+            CommandCodes.DELETE_CONSUMER_GROUP_CODE => VsrOperation.DeleteConsumerGroup,
+            CommandCodes.JOIN_CONSUMER_GROUP_CODE => VsrOperation.JoinConsumerGroup,
+            CommandCodes.LEAVE_CONSUMER_GROUP_CODE => VsrOperation.LeaveConsumerGroup,
+            _ => VsrOperation.NonReplicated
+        };
+    }
+
+    /// <summary>
+    ///     Whether the byte is a declared discriminant. Replies carry a server-controlled operation byte, so
+    ///     an undeclared value is rejected rather than classified by range.
+    /// </summary>
+    internal static bool IsKnown(byte value)
+    {
+        return (VsrOperation)value switch
+        {
+            VsrOperation.Reserved or VsrOperation.Register or VsrOperation.NonReplicated or VsrOperation.Logout =>
+                true,
+            >= VsrOperation.CreateTopicWithAssignments and <= VsrOperation.TruncatePartition => true,
+            >= VsrOperation.CreateStream and <= VsrOperation.LeaveConsumerGroup => true,
+            VsrOperation.SendMessages or VsrOperation.StoreConsumerOffset or VsrOperation.DeleteConsumerOffset
+                or VsrOperation.StoreConsumerOffset2 or VsrOperation.DeleteConsumerOffset2 => true,
+            _ => false
+        };
+    }
+
+    /// <summary>Replica / journal only; never emitted by a client.</summary>
+    internal static bool IsInternal(this VsrOperation operation)
+    {
+        return (byte)operation >= InternalStart && (byte)operation < MetadataStart;
+    }
+
+    /// <summary>
+    ///     Control-plane operations handled by shard 0. Enumerated member by member, mirroring
+    ///     <c>Operation::is_metadata</c>, rather than tested as a numeric range: the metadata block runs to 159
+    ///     but the last member declared today is 149, so a range would silently exclude the next operation added
+    ///     upstream. That operation would then also read as not result-framed, and a committed rejection in its
+    ///     reply would decode as a successful payload.
+    /// </summary>
+    internal static bool IsMetadata(this VsrOperation operation)
+    {
+        return operation.IsInternal() || operation is VsrOperation.CreateStream
+            or VsrOperation.UpdateStream
+            or VsrOperation.DeleteStream
+            or VsrOperation.PurgeStream
+            or VsrOperation.CreateTopic
+            or VsrOperation.UpdateTopic
+            or VsrOperation.DeleteTopic
+            or VsrOperation.PurgeTopic
+            or VsrOperation.CreatePartitions
+            or VsrOperation.DeletePartitions
+            or VsrOperation.CreateConsumerGroup
+            or VsrOperation.DeleteConsumerGroup
+            or VsrOperation.CreateUser
+            or VsrOperation.UpdateUser
+            or VsrOperation.DeleteUser
+            or VsrOperation.ChangePassword
+            or VsrOperation.UpdatePermissions
+            or VsrOperation.CreatePersonalAccessToken
+            or VsrOperation.DeletePersonalAccessToken
+            or VsrOperation.JoinConsumerGroup
+            or VsrOperation.LeaveConsumerGroup;
+    }
+
+    /// <summary>
+    ///     Data-plane operations routed by namespace to the shard owning the partition.
+    ///     <see cref="VsrOperation.DeleteSegments" /> is deliberately neither metadata nor partition: the
+    ///     server resolves it to an internal <c>TruncatePartition</c>, yet it still carries a packed namespace.
+    /// </summary>
+    internal static bool IsPartition(this VsrOperation operation)
+    {
+        return (byte)operation >= PartitionStart;
+    }
+
+    /// <summary>
+    ///     Whether a reply for this operation leads its body with the committed result section. Metadata ops
+    ///     always do; on the partition plane only the consumer-offset ops do. Register is result-framed only
+    ///     when its body is non-empty, which is why that case stays in <see cref="VsrReplyDecoder" />.
+    /// </summary>
+    internal static bool IsResultFramed(this VsrOperation operation)
+    {
+        return operation.IsMetadata() || operation is VsrOperation.StoreConsumerOffset
+            or VsrOperation.StoreConsumerOffset2
+            or VsrOperation.DeleteConsumerOffset
+            or VsrOperation.DeleteConsumerOffset2;
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK/Vsr/VsrReplyDecoder.cs b/foreign/csharp/Iggy_SDK/Vsr/VsrReplyDecoder.cs
new file mode 100644
index 000000000..efb213015
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK/Vsr/VsrReplyDecoder.cs
@@ -0,0 +1,206 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+
+namespace Apache.Iggy.Vsr;
+
+/// <summary>
+///     Decodes a reply frame into the typed payload the classic response readers expect.
+/// </summary>
+internal static class VsrReplyDecoder
+{
+    internal const int RESULT_COUNT_LENGTH = 4;
+    internal const int RESULT_ENTRY_LENGTH = 8;
+
+    /// <summary>
+    ///     Runs the decode funnel in the order the wire contract requires: eviction frames first, then the
+    ///     announced size, then the pre-commit status word, and only then the body and its committed result
+    ///     section. Reading the status before the body is what lets an empty deny reply fail with the right
+    ///     error instead of a truncation.
+    /// </summary>
+    internal static ReadOnlyMemory<byte> Decode(ReadOnlySpan<byte> header, ReadOnlyMemory<byte> body)
+    {
+        if (header.Length < VsrHeader.HEADER_SIZE)
+        {
+            throw VsrError.Exception(VsrError.EMPTY_RESPONSE, "Reply header is shorter than the consensus header.");
+        }
+
+        switch (VsrHeader.PeekCommand(header))
+        {
+            case Command2.Eviction:
+                throw ToException(VsrHeader.ReadEviction(header));
+            case Command2.Reply:
+                break;
+            default:
+                throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                    $"Unexpected consensus frame ({header[VsrHeader.COMMAND_OFFSET]}).");
+        }
+
+        var expectedBody = ReadBodySize(header);
+        if (body.Length < expectedBody)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "Reply body is shorter than the announced frame size.");
+        }
+
+        var status = VsrHeader.ReadStatus(header);
+        if (status != 0)
+        {
+            throw VsrError.FromServer((int)status, $"Server rejected the request with status {status}.");
+        }
+
+        return SplitResultSection(VsrHeader.ReadReplyOperation(header), body[..expectedBody]);
+    }
+
+    /// <summary>Body length announced by the frame, i.e. the bytes to read after the header.</summary>
+    internal static int ReadBodySize(ReadOnlySpan<byte> header)
+    {
+        var size = VsrHeader.ReadSize(header);
+        if (size < VsrHeader.HEADER_SIZE || size > int.MaxValue)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, $"Reply announced an invalid frame size ({size}).");
+        }
+
+        return (int)size - VsrHeader.HEADER_SIZE;
+    }
+
+    internal static Exception ToException(EvictionFrame eviction)
+    {
+        var (code, message) = eviction.Reason switch
+        {
+            // The five reasons below fall into the catch-all of the shared grader, so they carry INVALID_COMMAND
+            // even where a narrower status would read better. The message keeps the detail; the code is the part
+            // six SDKs agree on.
+            EvictionReason.ClientReleaseTooLow => (VsrError.INVALID_COMMAND,
+                "Client release is below the cluster minimum."),
+            EvictionReason.ClientReleaseTooHigh => (VsrError.INVALID_COMMAND,
+                "Client release is above the cluster maximum."),
+            EvictionReason.InvalidRequestOperation => (VsrError.INVALID_COMMAND,
+                "Server rejected the request operation."),
+            EvictionReason.InvalidRequestBody => (VsrError.INVALID_COMMAND, "Server rejected the request body."),
+            EvictionReason.InvalidRequestBodySize => (VsrError.INVALID_COMMAND,
+                "Server rejected the request body size."),
+            EvictionReason.InvalidCredentials => (VsrError.INVALID_CREDENTIALS, "Invalid credentials."),
+            EvictionReason.InvalidToken => (VsrError.INVALID_PERSONAL_ACCESS_TOKEN, "Invalid personal access token."),
+            EvictionReason.UserInactive => (VsrError.UNAUTHENTICATED, "User is inactive."),
+            EvictionReason.SessionError => (VsrError.UNAUTHENTICATED, "Session error."),
+            EvictionReason.NoSession => (VsrError.UNAUTHENTICATED, "No session for this client."),
+            EvictionReason.SessionTooLow => (VsrError.UNAUTHENTICATED, "Session is below the cluster minimum."),
+            EvictionReason.SessionReleaseMismatch => (VsrError.UNAUTHENTICATED, "Session release mismatch."),
+            EvictionReason.StaleClient => (VsrError.STALE_CLIENT, "Client missed too many heartbeats."),
+            EvictionReason.IncompatibleProtocol => IncompatibleProtocol(eviction),
+            EvictionReason.MalformedLogin => (VsrError.INVALID_FORMAT, "Malformed login body."),
+
+            // Reserved and any reason this build cannot decode share the grader's catch-all.
+            null => (VsrError.INVALID_COMMAND, "Session evicted for an unrecognized reason."),
+            _ => (VsrError.INVALID_COMMAND, $"Session evicted ({eviction.Reason}).")
+        };
+
+        return VsrError.FromServer(code, message);
+    }
+
+    /// <summary>
+    ///     Leading result code of a committed reply body: 0 for success, otherwise the first entry's result.
+    ///     <c>null</c> when the body cannot hold what the count claims - corruption, never a silent success.
+    /// </summary>
+    internal static uint? ReadResultCode(ReadOnlySpan<byte> body)
+    {
+        if (!TryReadUInt32(body, 0, out var count))
+        {
+            return null;
+        }
+
+        if (count == 0)
+        {
+            return 0;
+        }
+
+        return TryReadUInt32(body, RESULT_COUNT_LENGTH + 4, out var result) ? result : null;
+    }
+
+    /// <summary>Byte length of the leading result section, i.e. where the typed payload starts.</summary>
+    internal static int? ReadResultSectionLength(ReadOnlySpan<byte> body)
+    {
+        if (!TryReadUInt32(body, 0, out var count))
+        {
+            return null;
+        }
+
+        var length = RESULT_COUNT_LENGTH + (long)count * RESULT_ENTRY_LENGTH;
+
+        return body.Length >= length ? (int)length : null;
+    }
+
+    private static (int Code, string Message) IncompatibleProtocol(EvictionFrame eviction)
+    {
+        if (eviction.ServerProtocolVersionMin == 0 ||
+            eviction.ServerProtocolVersion < eviction.ServerProtocolVersionMin)
+        {
+            return (VsrError.UNAUTHENTICATED, "Server rejected the client protocol version.");
+        }
+
+        return (VsrError.INCOMPATIBLE_PROTOCOL_VERSION,
+            $"Client protocol version {LoginRegister.PROTOCOL_VERSION} is outside the range accepted by the server " +
+            $"({eviction.ServerProtocolVersionMin}..{eviction.ServerProtocolVersion}).");
+    }
+
+    /// <summary>
+    ///     Strips the committed result section from a result-framed reply and maps a committed rejection to
+    ///     its error. Register replies are result-framed only when non-empty: a terminal register failure
+    ///     ships an empty body and is passed through to fail the typed response decode.
+    /// </summary>
+    private static ReadOnlyMemory<byte> SplitResultSection(VsrOperation operation, ReadOnlyMemory<byte> body)
+    {
+        var resultFramed = operation.IsResultFramed() ||
+                           (operation == VsrOperation.Register && !body.IsEmpty);
+        if (!resultFramed)
+        {
+            return body;
+        }
+
+        var code = ReadResultCode(body.Span);
+        if (code is null)
+        {
+            throw VsrError.Exception(VsrError.INVALID_COMMAND, "Reply carries a malformed committed result section.");
+        }
+
+        if (code != 0)
+        {
+            throw VsrError.FromServer((int)code.Value, $"Server rejected the request with status {code.Value}.");
+        }
+
+        var payloadStart = ReadResultSectionLength(body.Span)
+                           ?? throw VsrError.Exception(VsrError.INVALID_COMMAND,
+                               "Reply carries a truncated committed result section.");
+
+        return body[payloadStart..];
+    }
+
+    private static bool TryReadUInt32(ReadOnlySpan<byte> body, int offset, out uint value)
+    {
+        if (body.Length < offset + 4)
+        {
+            value = 0;
+
+            return false;
+        }
+
+        value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(offset, 4));
+
+        return true;
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
new file mode 100644
index 000000000..81f5a69c4
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/ClientTests/IggyClientFactoryTests.cs
@@ -0,0 +1,114 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Configuration;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Factory;
+
+namespace Apache.Iggy.Tests.ClientTests;
+
+public sealed class IggyClientFactoryTests
+{
+    [Fact]
+    public void CreateClient_DefaultsToClassicWireProtocol()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "127.0.0.1:8090",
+            Protocol = Protocol.Tcp
+        };
+
+        Assert.Equal(WireProtocol.Classic, options.WireProtocol);
+        Assert.Equal(64 * 1024 * 1024, options.MaxResponseFrameSize);
+
+        using var client = IggyClientFactory.CreateClient(options) as IDisposable;
+        Assert.NotNull(client);
+    }
+
+    [Fact]
+    public void CreateClient_AllowsVsrOverTcp()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "127.0.0.1:8090",
+            Protocol = Protocol.Tcp,
+            WireProtocol = WireProtocol.Vsr
+        };
+
+        using var client = IggyClientFactory.CreateClient(options) as IDisposable;
+        Assert.NotNull(client);
+    }
+
+    [Fact]
+    public void CreateClient_RejectsVsrOverHttp()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "http://127.0.0.1:3000",
+            Protocol = Protocol.Http,
+            WireProtocol = WireProtocol.Vsr
+        };
+
+        var exception = Assert.Throws<ArgumentException>(() => IggyClientFactory.CreateClient(options));
+        Assert.Contains("WireProtocol.Vsr requires Protocol.Tcp", exception.Message);
+    }
+
+    [Fact]
+    public void CreateClient_RejectsMaxResponseFrameSizeBelowHeader()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "127.0.0.1:8090",
+            Protocol = Protocol.Tcp,
+            WireProtocol = WireProtocol.Vsr,
+            MaxResponseFrameSize = 255
+        };
+
+        Assert.Throws<ArgumentOutOfRangeException>(() => IggyClientFactory.CreateClient(options));
+    }
+
+    /// <summary>
+    ///     Only the VSR reader bounds the buffer it rents for a peer-announced length, so a classic client is
+    ///     not failed over a value that never reaches its path.
+    /// </summary>
+    [Fact]
+    public void CreateClient_AcceptsMaxResponseFrameSizeBelowHeaderUnderClassic()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "127.0.0.1:8090",
+            Protocol = Protocol.Tcp,
+            MaxResponseFrameSize = 1
+        };
+
+        Assert.NotNull(IggyClientFactory.CreateClient(options));
+    }
+
+    [Fact]
+    public void CreateClient_AcceptsMaxResponseFrameSizeUnderHttp()
+    {
+        var options = new IggyClientConfigurator
+        {
+            BaseAddress = "http://127.0.0.1:3000",
+            Protocol = Protocol.Http,
+            MaxResponseFrameSize = 1
+        };
+
+        using var client = IggyClientFactory.CreateClient(options) as IDisposable;
+        Assert.NotNull(client);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
index db0af7c3f..41145dd5a 100644
--- a/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
+++ b/foreign/csharp/Iggy_SDK_Tests/ConsumerTests/IggyConsumerBuilderTests.cs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using System.Text;
 using Apache.Iggy.Consumers;
 using Apache.Iggy.Encryption;
 using Apache.Iggy.Enums;
@@ -69,4 +70,57 @@ public class IggyConsumerBuilderTests
 
         Assert.NotNull(consumer);
     }
+
+    /// <summary>VSR is a framing choice, not a transport one, so the builder has to carry it to the client.</summary>
+    [Fact]
+    public void WithWireProtocol_CarriesTheFramingToTheConfig()
+    {
+        var builder = IggyConsumerBuilder
+            .Create(StreamId, TopicId, Consumer.New(1))
+            .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass")
+                .WithWireProtocol(WireProtocol.Vsr);
+
+        Assert.Equal(WireProtocol.Vsr, builder.Config.WireProtocol);
+    }
+
+    [Fact]
+    public void WithConnection_DefaultsToClassicFraming()
+    {
+        var builder = IggyConsumerBuilder
+            .Create(StreamId, TopicId, Consumer.New(1))
+            .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass");
+
+        Assert.Equal(WireProtocol.Classic, builder.Config.WireProtocol);
+    }
+
+    [Fact]
+    public void TypedBuild_WithVsr_CreatesTheClient()
+    {
+        IggyConsumerBuilder<string> builder = IggyConsumerBuilder<string>
+            .Create(StreamId, TopicId, Consumer.New(1), new StringDeserializer());
+        builder.WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass")
+            .WithWireProtocol(WireProtocol.Vsr);
+
+        Assert.NotNull(builder.Build());
+    }
+
+    [Fact]
+    public void TypedBuild_WithVsrOverHttp_Throws()
+    {
+        IggyConsumerBuilder<string> builder = IggyConsumerBuilder<string>
+            .Create(StreamId, TopicId, Consumer.New(1), new StringDeserializer());
+        builder.WithConnection(Protocol.Http, "http://127.0.0.1:3000", "user", "pass")
+            .WithWireProtocol(WireProtocol.Vsr);
+
+        var ex = Assert.Throws<ArgumentException>(() => builder.Build());
+        Assert.Contains("WireProtocol.Vsr requires Protocol.Tcp", ex.Message);
+    }
+
+    private sealed class StringDeserializer : IDeserializer<string>
+    {
+        public string Deserialize(ReadOnlyMemory<byte> data)
+        {
+            return Encoding.UTF8.GetString(data.Span);
+        }
+    }
 }
diff --git a/foreign/csharp/Iggy_SDK_Tests/PublisherTests/IggyPublisherBuilderTests.cs b/foreign/csharp/Iggy_SDK_Tests/PublisherTests/IggyPublisherBuilderTests.cs
new file mode 100644
index 000000000..ac97a98a8
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/PublisherTests/IggyPublisherBuilderTests.cs
@@ -0,0 +1,75 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers;
+using System.Text;
+using Apache.Iggy.Enums;
+using Apache.Iggy.Publishers;
+
+namespace Apache.Iggy.Tests.PublisherTests;
+
+public class IggyPublisherBuilderTests
+{
+    private static readonly Identifier StreamId = Identifier.Numeric(1);
+    private static readonly Identifier TopicId = Identifier.Numeric(1);
+
+    [Fact]
+    public void WithWireProtocol_CarriesTheFramingToTheConfig()
+    {
+        var builder = IggyPublisherBuilder
+            .Create(StreamId, TopicId)
+            .WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass")
+                .WithWireProtocol(WireProtocol.Vsr);
+
+        Assert.Equal(WireProtocol.Vsr, builder.Config.WireProtocol);
+    }
+
+    [Fact]
+    public void TypedBuild_WithVsr_CreatesTheClient()
+    {
+        IggyPublisherBuilder<string> builder
+            = IggyPublisherBuilder<string>.Create(StreamId, TopicId, new StringSerializer());
+        builder.WithConnection(Protocol.Tcp, "127.0.0.1:8090", "user", "pass")
+            .WithWireProtocol(WireProtocol.Vsr);
+
+        Assert.NotNull(builder.Build());
+    }
+
+    /// <summary>
+    ///     The factory only rejects VSR over HTTP when the builder actually forwards the wire protocol, so the
+    ///     rejection is what proves the typed builder does not silently drop it and fall back to classic framing.
+    /// </summary>
+    [Fact]
+    public void TypedBuild_WithVsrOverHttp_Throws()
+    {
+        IggyPublisherBuilder<string> builder
+            = IggyPublisherBuilder<string>.Create(StreamId, TopicId, new StringSerializer());
+        builder.WithConnection(Protocol.Http, "http://127.0.0.1:3000", "user", "pass")
+            .WithWireProtocol(WireProtocol.Vsr);
+
+        var ex = Assert.Throws<ArgumentException>(() => builder.Build());
+        Assert.Contains("WireProtocol.Vsr requires Protocol.Tcp", ex.Message);
+    }
+
+    private sealed class StringSerializer : ISerializer<string>
+    {
+        public void Serialize(string data, IBufferWriter<byte> writer)
+        {
+            writer.Write(Encoding.UTF8.GetBytes(data));
+        }
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsensusSessionTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsensusSessionTests.cs
new file mode 100644
index 000000000..51e4d7f0a
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsensusSessionTests.cs
@@ -0,0 +1,218 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+public sealed class ConsensusSessionTests
+{
+    [Fact]
+    public void NewSession_IsUnboundWithNonZeroClientId()
+    {
+        var session = new ConsensusSession();
+
+        Assert.False(session.IsBound);
+        Assert.Null(session.Session);
+        Assert.NotEqual(UInt128.Zero, session.ClientId);
+    }
+
+    [Fact]
+    public void NewSession_MintsUniqueClientIds()
+    {
+        Assert.NotEqual(new ConsensusSession().ClientId, new ConsensusSession().ClientId);
+    }
+
+    [Fact]
+    public void BeginRegister_OnFreshSessionKeepsClientId()
+    {
+        var session = new ConsensusSession(7);
+
+        Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId);
+        Assert.Equal((UInt128)7, session.ClientId);
+        Assert.False(session.IsBound);
+    }
+
+    /// <summary>
+    ///     The re-arm mints the client id the register is encoded with, so the frame must carry the new one, not
+    ///     the one the previous session used.
+    /// </summary>
+    [Fact]
+    public void Resolve_RegisterAfterBindReportsTheReArmedClientId()
+    {
+        var session = new ConsensusSession(7);
+        session.Resolve(VsrOperation.Register);
+        session.Bind(42);
+
+        var frame = session.Resolve(VsrOperation.Register);
+
+        Assert.Equal(session.ClientId, frame.ClientId);
+        Assert.NotEqual((UInt128)7, frame.ClientId);
+    }
+
+    [Fact]
+    public void BeginRegister_AfterBindReArmsWithFreshClientId()
+    {
+        var session = new ConsensusSession(7);
+        session.Resolve(VsrOperation.Register);
+        session.Bind(42);
+        session.Resolve(VsrOperation.CreateStream);
+
+        Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId);
+        Assert.NotEqual((UInt128)7, session.ClientId);
+        Assert.False(session.IsBound);
+        Assert.Equal(1UL, session.RequestCounter);
+    }
+
+    /// <summary>
+    ///     A register that never bound is cleared by the reset its failure path runs, and the retry re-arms onto a
+    ///     fresh client id rather than reusing the one the server may have already seen.
+    /// </summary>
+    [Fact]
+    public void BeginRegister_AfterConsumedRegisterAndResetReArms()
+    {
+        var session = new ConsensusSession(7);
+        session.Resolve(VsrOperation.Register);
+
+        session.Reset();
+
+        Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId);
+        Assert.False(session.IsBound);
+        Assert.NotEqual((UInt128)7, session.ClientId);
+    }
+
+    [Fact]
+    public void NextRequestId_IsMonotonicAfterBind()
+    {
+        var session = new ConsensusSession(1);
+        session.Resolve(VsrOperation.Register);
+        session.Bind(10);
+
+        Assert.Equal(1UL, session.Resolve(VsrOperation.CreateStream).RequestId);
+        Assert.Equal(2UL, session.Resolve(VsrOperation.CreateStream).RequestId);
+        Assert.Equal(3UL, session.RequestCounter);
+    }
+
+    [Fact]
+    public void NextRequestId_BeforeBindThrows()
+    {
+        var session = new ConsensusSession(1);
+
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() => session.Resolve(VsrOperation.CreateStream));
+
+        Assert.Equal(VsrError.UNAUTHENTICATED, error.StatusCode);
+    }
+
+    [Fact]
+    public void Resolve_DoesNotConsumeAnIdForNonReplicatedOrPartitionOps()
+    {
+        var session = new ConsensusSession(1);
+        session.Resolve(VsrOperation.Register);
+        session.Bind(10);
+
+        Assert.Equal(1UL, session.Resolve(VsrOperation.NonReplicated).RequestId);
+        Assert.Equal(1UL, session.Resolve(VsrOperation.SendMessages).RequestId);
+        Assert.Equal(1UL, session.RequestCounter);
+    }
+
+    [Fact]
+    public void Bind_TwiceThrows()
+    {
+        var session = new ConsensusSession(1);
+        session.Resolve(VsrOperation.Register);
+        session.Bind(10);
+
+        Assert.Throws<NotConnectedException>(() => session.Bind(20));
+        Assert.Equal(10UL, session.Session);
+    }
+
+    [Fact]
+    public void Bind_ZeroThrows()
+    {
+        var session = new ConsensusSession(1);
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() => session.Bind(0));
+        Assert.Equal(VsrError.INVALID_FORMAT, exception.StatusCode);
+    }
+
+    [Fact]
+    public void Bind_WithoutAnInFlightRegisterThrows()
+    {
+        var session = new ConsensusSession(1);
+
+        Assert.Throws<NotConnectedException>(() => session.Bind(10));
+    }
+
+    /// <summary>
+    ///     Binding runs after the sending lock is released, so a drop can have re-armed the identity since the
+    ///     register committed. The reset clears the pending register, and binding regardless would pair the
+    ///     session the server issued to the old client id with the one the re-arm minted.
+    /// </summary>
+    [Fact]
+    public void Bind_AfterTheIdentityReArmedThrows()
+    {
+        var session = new ConsensusSession(1);
+        session.Resolve(VsrOperation.Register);
+
+        session.Reset();
+
+        Assert.Throws<NotConnectedException>(() => session.Bind(10));
+        Assert.False(session.IsBound);
+    }
+
+    /// <summary>
+    ///     Two concurrent registers would otherwise re-arm the identity under the first one, so its bind would
+    ///     pair a committed session with a client id the server never saw.
+    /// </summary>
+    [Fact]
+    public void Resolve_SecondRegisterWhileOneIsInFlightThrows()
+    {
+        var session = new ConsensusSession(1);
+        session.Resolve(VsrOperation.Register);
+
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() => session.Resolve(VsrOperation.Register));
+
+        Assert.Equal(VsrError.UNAUTHENTICATED, error.StatusCode);
+    }
+
+    [Fact]
+    public void Resolve_RegisterIsAllowedAgainAfterReset()
+    {
+        var session = new ConsensusSession(1);
+        session.Resolve(VsrOperation.Register);
+
+        session.Reset();
+
+        Assert.Equal(0UL, session.Resolve(VsrOperation.Register).RequestId);
+    }
+
+    [Fact]
+    public void Reset_ClearsBindingAndCounter()
+    {
+        var session = new ConsensusSession(1);
+        session.Resolve(VsrOperation.Register);
+        session.Bind(10);
+        session.Resolve(VsrOperation.CreateStream);
+
+        session.Reset();
+
+        Assert.False(session.IsBound);
+        Assert.Equal(1UL, session.RequestCounter);
+        Assert.NotEqual((UInt128)1, session.ClientId);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
new file mode 100644
index 000000000..c1c4f00ad
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ConsumerGroupClientStateTests.cs
@@ -0,0 +1,176 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.IO.Hashing;
+using System.Text;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+public sealed class ConsumerGroupClientStateTests
+{
+    private static readonly GroupKey Key =
+        new(Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3));
+
+    private static readonly TopicKey Topic = new(Identifier.Numeric(1), Identifier.Numeric(2));
+
+    [Fact]
+    public void NextGroupPartition_RoundRobinsThenWraps()
+    {
+        var state = new ConsumerGroupClientState();
+        state.SetAssignment(Key, 1, [0, 1, 2]);
+
+        Assert.Equal<uint?[]>([0, 1, 2, 0], [
+            state.NextGroupPartition(Key), state.NextGroupPartition(Key),
+            state.NextGroupPartition(Key), state.NextGroupPartition(Key)
+        ]);
+    }
+
+    [Fact]
+    public void SetAssignment_OnNewGenerationResetsCursor()
+    {
+        var state = new ConsumerGroupClientState();
+        state.SetAssignment(Key, 1, [0, 1, 2]);
+        state.NextGroupPartition(Key);
+        state.NextGroupPartition(Key);
+
+        state.SetAssignment(Key, 2, [5]);
+
+        Assert.Equal(5u, state.NextGroupPartition(Key));
+    }
+
+    [Fact]
+    public void SetAssignment_OnSameGenerationKeepsCursor()
+    {
+        var state = new ConsumerGroupClientState();
+        state.SetAssignment(Key, 1, [0, 1, 2]);
+        state.NextGroupPartition(Key);
+
+        state.SetAssignment(Key, 1, [0, 1, 2]);
+
+        Assert.Equal(1u, state.NextGroupPartition(Key));
+    }
+
+    [Fact]
+    public void NextGroupPartition_WithoutAssignmentIsNull()
+    {
+        var state = new ConsumerGroupClientState();
+
+        Assert.False(state.HasAssignment(Key));
+        Assert.Null(state.NextGroupPartition(Key));
+    }
+
+    [Fact]
+    public void InvalidateAssignment_KeepsMembership()
+    {
+        var state = new ConsumerGroupClientState();
+        state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3));
+        state.SetAssignment(Key, 1, [0]);
+
+        state.InvalidateAssignment(Key);
+
+        Assert.False(state.HasAssignment(Key));
+        Assert.True(state.IsRegistered(Key));
+    }
+
+    [Fact]
+    public void MemberHoldingNoPartitions_StaysRegistered()
+    {
+        var state = new ConsumerGroupClientState();
+        state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3));
+        state.SetAssignment(Key, 1, []);
+
+        Assert.False(state.HasAssignment(Key));
+        Assert.True(state.IsRegistered(Key));
+
+        state.DeregisterGroup(Key);
+
+        Assert.False(state.IsRegistered(Key));
+    }
+
+    [Fact]
+    public void RegisteredGroups_ReturnsJoinedIdentifiers()
+    {
+        var state = new ConsumerGroupClientState();
+        state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.String("group"));
+
+        IReadOnlyList<ConsumerGroupClientState.GroupIdentifiers> groups = state.RegisteredGroups();
+
+        var group = Assert.Single(groups);
+
+        Assert.Equal("1", group.StreamId.ToString());
+        Assert.Equal("2", group.TopicId.ToString());
+        Assert.Equal("group", group.GroupId.ToString());
+    }
+
+    [Fact]
+    public void NextBalancedPartition_RoundRobinsThenWraps()
+    {
+        var state = new ConsumerGroupClientState();
+
+        Assert.Equal<uint[]>([0, 1, 2, 0], [
+            state.NextBalancedPartition(Topic, 3), state.NextBalancedPartition(Topic, 3),
+            state.NextBalancedPartition(Topic, 3), state.NextBalancedPartition(Topic, 3)
+        ]);
+    }
+
+    [Fact]
+    public void NextBalancedPartition_WithNoPartitionsIsZero()
+    {
+        Assert.Equal(0u, new ConsumerGroupClientState().NextBalancedPartition(Topic, 0));
+    }
+
+    [Fact]
+    public void ClearSessionScoped_DropsMembershipAndAssignmentsButKeepsTopicState()
+    {
+        var state = new ConsumerGroupClientState();
+        state.RegisterGroup(Key, Identifier.Numeric(1), Identifier.Numeric(2), Identifier.Numeric(3));
+        state.SetAssignment(Key, 1, [0]);
+        state.SetPartitionCount(Topic, 4);
+        state.NextBalancedPartition(Topic, 4);
+
+        state.ClearSessionScoped();
+
+        Assert.False(state.IsRegistered(Key));
+        Assert.False(state.HasAssignment(Key));
+        Assert.Equal(4u, state.PartitionCount(Topic));
+        Assert.Equal(1u, state.NextBalancedPartition(Topic, 4));
+    }
+
+    [Fact]
+    public void TopicKey_SeparatesNumericFromNamedIdentifiers()
+    {
+        Assert.NotEqual(new TopicKey(Identifier.Numeric(1), Identifier.Numeric(1)),
+            new TopicKey(Identifier.String("1"), Identifier.String("1")));
+    }
+
+    /// <summary>
+    ///     Message-key partitioning has to agree with the Rust client byte for byte, or the two SDKs put the same
+    ///     key on different partitions. Vectors come from <c>calculate_32</c> (<c>XxHash32::oneshot(0, data)</c>).
+    /// </summary>
+    [Theory]
+    [InlineData("", 0x02cc5d05u)]
+    [InlineData("a", 0x550d7456u)]
+    [InlineData("abc", 0x32d153ffu)]
+    [InlineData("hello world", 0xcebb6622u)]
+    [InlineData("iggy-message-key", 0xf54b51c9u)]
+    [InlineData("1234567890123456789012345", 0xb10c970eu)]
+    public void XxHash32_MatchesRustVectors(string value, uint expected)
+    {
+        Assert.Equal(expected, XxHash32.HashToUInt32(Encoding.UTF8.GetBytes(value)));
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/CredentialBoundsTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/CredentialBoundsTests.cs
new file mode 100644
index 000000000..4fc2e6b30
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/CredentialBoundsTests.cs
@@ -0,0 +1,72 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+/// <summary>
+///     Mirrors <c>validate_username</c> / <c>validate_password</c> in
+///     <c>core/common/src/traits/binary_impls/mod.rs</c>.
+/// </summary>
+public sealed class CredentialBoundsTests
+{
+    [Fact]
+    public void ValidateUsername_AcceptsTheServerBounds()
+    {
+        CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MIN_USERNAME_LENGTH));
+        CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MAX_USERNAME_LENGTH));
+    }
+
+    [Fact]
+    public void ValidateUsername_RejectsOutOfBounds()
+    {
+        AssertRejects(VsrError.INVALID_USERNAME,
+            () => CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MIN_USERNAME_LENGTH - 1)));
+        AssertRejects(VsrError.INVALID_USERNAME,
+            () => CredentialBounds.ValidateUsername(new string('a', CredentialBounds.MAX_USERNAME_LENGTH + 1)));
+    }
+
+    [Fact]
+    public void ValidatePassword_AcceptsTheServerBounds()
+    {
+        CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MIN_PASSWORD_LENGTH));
+        CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MAX_PASSWORD_LENGTH));
+    }
+
+    [Fact]
+    public void ValidatePassword_RejectsOutOfBounds()
+    {
+        AssertRejects(VsrError.INVALID_PASSWORD,
+            () => CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MIN_PASSWORD_LENGTH - 1)));
+        AssertRejects(VsrError.INVALID_PASSWORD,
+            () => CredentialBounds.ValidatePassword(new string('a', CredentialBounds.MAX_PASSWORD_LENGTH + 1)));
+    }
+
+    [Fact]
+    public void ValidatePassword_CountsUtf8BytesNotChars()
+    {
+        // 51 two-byte code points encode to 102 bytes, over the server's limit despite fitting in chars.
+        AssertRejects(VsrError.INVALID_PASSWORD, () => CredentialBounds.ValidatePassword(new string('ĆŒ', 51)));
+    }
+
+    private static void AssertRejects(int statusCode, Action action)
+    {
+        Assert.Equal(statusCode, Assert.Throws<IggyInvalidStatusCodeException>(action).StatusCode);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/LoginRegisterTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/LoginRegisterTests.cs
new file mode 100644
index 000000000..896c11b6d
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/LoginRegisterTests.cs
@@ -0,0 +1,171 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using System.Text;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+public sealed class LoginRegisterTests
+{
+    private static int AssertVersionInfo(byte[] body)
+    {
+        Assert.Equal(LoginRegister.PROTOCOL_VERSION, BinaryPrimitives.ReadUInt32LittleEndian(body));
+        var position = 4;
+        position += AssertName(body, position, LoginRegister.SDK_NAME);
+        position += AssertName(body, position, SdkVersion.Value);
+
+        return position;
+    }
+
+    private static int AssertName(byte[] body, int position, string expected)
+    {
+        var length = body[position];
+        Assert.Equal(Encoding.UTF8.GetByteCount(expected), length);
+        Assert.Equal(expected, Encoding.UTF8.GetString(body, position + 1, length));
+
+        return 1 + length;
+    }
+
+    [Fact]
+    public void ProtocolVersion_PacksTenBitsPerComponent()
+    {
+        Assert.Equal((0u << 20) | (10u << 10) | 3u, LoginRegister.PROTOCOL_VERSION);
+    }
+
+    [Fact]
+    public void Serialize_WritesVersionInfoThenCredentialsThenContext()
+    {
+        var body = LoginRegister.Serialize("admin", "secret");
+
+        var position = AssertVersionInfo(body);
+        position += AssertName(body, position, "admin");
+        position += AssertName(body, position, "secret");
+
+        Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(position)));
+        Assert.Equal(position + 4, body.Length);
+    }
+
+    [Fact]
+    public void Serialize_AppendsTheClientContextWithAUInt32Length()
+    {
+        var body = LoginRegister.Serialize("admin", "secret", "ctx");
+
+        var position = AssertVersionInfo(body);
+        position += AssertName(body, position, "admin");
+        position += AssertName(body, position, "secret");
+
+        Assert.Equal(3u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(position)));
+        Assert.Equal("ctx", Encoding.UTF8.GetString(body, position + 4, 3));
+        Assert.Equal(position + 7, body.Length);
+    }
+
+    [Fact]
+    public void SerializeWithPersonalAccessToken_PutsTheTokenInTheCredentialSlot()
+    {
+        var body = LoginRegister.SerializeWithPersonalAccessToken("token");
+
+        var position = AssertVersionInfo(body);
+        position += AssertName(body, position, "token");
+
+        Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(position)));
+        Assert.Equal(position + 4, body.Length);
+    }
+
+    [Fact]
+    public void Serialize_RejectsAnEmptyCredential()
+    {
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            LoginRegister.Serialize("admin", string.Empty));
+
+        Assert.Equal(VsrError.INVALID_PASSWORD, error.StatusCode);
+    }
+
+    [Fact]
+    public void Serialize_RejectsACredentialAboveTheLengthPrefix()
+    {
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            LoginRegister.Serialize("admin", new string('x', 256)));
+
+        Assert.Equal(VsrError.INVALID_PASSWORD, error.StatusCode);
+    }
+
+    /// <summary>
+    ///     The u8 length prefix is also guarded inside the writer, for the fields no credential check covers.
+    /// </summary>
+    [Fact]
+    public void SerializeWithPersonalAccessToken_RejectsATokenAboveTheLengthPrefix()
+    {
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            LoginRegister.SerializeWithPersonalAccessToken(new string('x', 256)));
+
+        Assert.Equal(VsrError.INVALID_PERSONAL_ACCESS_TOKEN, error.StatusCode);
+    }
+
+    [Fact]
+    public void SerializeWithPersonalAccessToken_RejectsAnEmptyToken()
+    {
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            LoginRegister.SerializeWithPersonalAccessToken(string.Empty));
+
+        Assert.Equal(VsrError.INVALID_PERSONAL_ACCESS_TOKEN, error.StatusCode);
+    }
+
+    [Fact]
+    public void Deserialize_ReadsTheRegisterReply()
+    {
+        var body = VsrTestPayloads.Concat(VsrTestPayloads.UInt32(42), new byte[8], VsrTestPayloads.UInt32(10243),
+            [5], "0.8.0"u8.ToArray());
+        BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(4), 100);
+
+        var response = LoginRegister.Deserialize(body);
+
+        Assert.Equal(42u, response.UserId);
+        Assert.Equal(100UL, response.Session);
+        Assert.Equal(10243u, response.ServerProtocolVersion);
+        Assert.Equal("0.8.0", response.ServerVersion);
+    }
+
+    [Fact]
+    public void Deserialize_TruncatedReplyIsInvalidFormat()
+    {
+        var body = VsrTestPayloads.Concat(VsrTestPayloads.UInt32(42), new byte[8], VsrTestPayloads.UInt32(10243),
+            [5], "0.8.0"u8.ToArray());
+
+        for (var length = 0; length < body.Length; length++)
+        {
+            var truncated = body[..length];
+            var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+                LoginRegister.Deserialize(truncated));
+
+            Assert.Equal(VsrError.INVALID_FORMAT, exception.StatusCode);
+        }
+    }
+
+    [Fact]
+    public void Deserialize_EmptyReplyIsTheTerminalRegisterRejection()
+    {
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            LoginRegister.Deserialize(ReadOnlySpan<byte>.Empty));
+
+        Assert.Equal(VsrError.INVALID_FORMAT, exception.StatusCode);
+        Assert.Contains("rejected the login", exception.Message);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/ServerAddressTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ServerAddressTests.cs
new file mode 100644
index 000000000..756711fc4
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/ServerAddressTests.cs
@@ -0,0 +1,85 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Utils;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+/// <summary>
+///     Mirrors <c>core/sdk/src/leader_aware.rs</c> <c>test_is_same_address</c> and
+///     <c>test_normalize_address</c>: the leader redirect check has to agree with the Rust SDK, or the two
+///     disagree on whether a roster entry names the node this connection is already on.
+/// </summary>
+public sealed class ServerAddressTests
+{
+    [Theory]
+    [InlineData("127.0.0.1:8090", "127.0.0.1:8090")]
+    [InlineData("localhost:8090", "127.0.0.1:8090")]
+    [InlineData("LOCALHOST:8090", "127.0.0.1:8090")]
+    [InlineData("[::1]:8090", "[::1]:8090")]
+    public void IsSame_MatchesEquivalentEndpoints(string first, string second)
+    {
+        Assert.True(ServerAddress.IsSame(first, second));
+        Assert.True(ServerAddress.IsSame(second, first));
+    }
+
+    [Theory]
+    [InlineData("127.0.0.1:8090", "127.0.0.1:8091")]
+    [InlineData("192.168.1.1:8090", "127.0.0.1:8090")]
+    [InlineData("localhost:8090", "127.0.0.1:8091")]
+    [InlineData("iggy-1:8090", "iggy-2:8090")]
+    [InlineData("127.0.0.1:8090", "")]
+    public void IsSame_SeparatesDistinctEndpoints(string first, string second)
+    {
+        Assert.False(ServerAddress.IsSame(first, second));
+        Assert.False(ServerAddress.IsSame(second, first));
+    }
+
+    [Theory]
+    [InlineData("localhost:8090", "127.0.0.1:8090")]
+    [InlineData("LOCALHOST:8090", "127.0.0.1:8090")]
+    [InlineData("[::]:8090", "[::1]:8090")]
+    [InlineData("0.0.0.0:8090", "127.0.0.1:8090")]
+    [InlineData("my-localhost-1:8090", "my-localhost-1:8090")]
+    public void Normalize_ResolvesHostAliases(string address, string expected)
+    {
+        Assert.Equal(expected, ServerAddress.Normalize(address));
+    }
+
+    /// <summary>
+    ///     A host name that merely contains an alias is a different node, and a server bound to the unspecified
+    ///     address answers on the loopback one.
+    /// </summary>
+    [Theory]
+    [InlineData("my-localhost-1:8090", "127.0.0.1:8090", false)]
+    [InlineData("localhost.example.com:8090", "127.0.0.1:8090", false)]
+    [InlineData("0.0.0.0:8090", "127.0.0.1:8090", true)]
+    [InlineData("[::]:8090", "[::1]:8090", true)]
+    [InlineData("0.0.0.0:8090", "[::1]:8090", false)]
+    public void IsSame_ResolvesHostAliasesWithoutSubstringMatching(string first, string second, bool same)
+    {
+        Assert.Equal(same, ServerAddress.IsSame(first, second));
+        Assert.Equal(same, ServerAddress.IsSame(second, first));
+    }
+
+    [Fact]
+    public void IsSame_FallsBackToNormalizedStringsForUnparseableAddresses()
+    {
+        Assert.True(ServerAddress.IsSame("Iggy-Node:8090", "iggy-node:8090"));
+        Assert.False(ServerAddress.IsSame("iggy-node:8090", "iggy-node:8091"));
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/SyncConsumerGroupTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/SyncConsumerGroupTests.cs
new file mode 100644
index 000000000..1d185668b
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/SyncConsumerGroupTests.cs
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+public sealed class SyncConsumerGroupTests
+{
+    [Fact]
+    public void Decode_ReadsGenerationAndPartitions()
+    {
+        var assignment = SyncConsumerGroupAssignment.Decode(Encode(7, [0, 2, 4]));
+
+        Assert.Equal(7ul, assignment.Generation);
+        Assert.Equal<uint[]>([0, 2, 4], [.. assignment.Partitions]);
+    }
+
+    [Fact]
+    public void Decode_ReadsEmptyAssignment()
+    {
+        var assignment = SyncConsumerGroupAssignment.Decode(Encode(1, []));
+
+        Assert.Equal(1ul, assignment.Generation);
+        Assert.Empty(assignment.Partitions);
+    }
+
+    [Fact]
+    public void Decode_IgnoresTrailingBytes()
+    {
+        var body = Encode(1, [3]).Concat(new byte[8]).ToArray();
+
+        Assert.Equal<uint[]>([3], [.. SyncConsumerGroupAssignment.Decode(body).Partitions]);
+    }
+
+    [Fact]
+    public void Decode_TruncatedBodyThrows()
+    {
+        var body = Encode(7, [1, 2]);
+        for (var length = 0; length < body.Length; length++)
+        {
+            var truncated = body[..length];
+            var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+                SyncConsumerGroupAssignment.Decode(truncated));
+
+            Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode);
+        }
+    }
+
+    /// <summary>
+    ///     A count the body cannot back must fail rather than allocate for it: the value is attacker-reachable
+    ///     through a corrupted frame.
+    /// </summary>
+    [Fact]
+    public void Decode_ImplausiblePartitionCountThrows()
+    {
+        var body = new byte[12];
+        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8, 4), uint.MaxValue);
+
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() => SyncConsumerGroupAssignment.Decode(body));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode);
+    }
+
+    private static byte[] Encode(ulong generation, uint[] partitions)
+    {
+        var body = new byte[12 + partitions.Length * 4];
+        BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(0, 8), generation);
+        BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8, 4), (uint)partitions.Length);
+        for (var i = 0; i < partitions.Length; i++)
+        {
+            BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12 + i * 4, 4), partitions[i]);
+        }
+
+        return body;
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrHeaderTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrHeaderTests.cs
new file mode 100644
index 000000000..f35843ff3
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrHeaderTests.cs
@@ -0,0 +1,292 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+public sealed class VsrHeaderTests
+{
+    private static ConsensusSession BoundSession(ulong session = 5)
+    {
+        var consensusSession = new ConsensusSession(0x0102_0304_0506_0708);
+        consensusSession.Resolve(VsrOperation.Register);
+        consensusSession.Bind(session);
+
+        return consensusSession;
+    }
+
+    private static byte[] Encode(ConsensusSession session, int code, byte[] payload, out int totalSize)
+    {
+        var header = new byte[VsrHeader.HEADER_SIZE];
+        totalSize = VsrHeader.EncodeRequestHeader(header, session, code, payload);
+
+        return header;
+    }
+
+    private static ulong ReadUInt64(byte[] header, int offset)
+    {
+        return BinaryPrimitives.ReadUInt64LittleEndian(header.AsSpan(offset));
+    }
+
+    private static uint ReadUInt32(byte[] header, int offset)
+    {
+        return BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(offset));
+    }
+
+    /// <summary>
+    ///     A namespace failure must leave the session exactly as it was. Consuming the request id would gap the
+    ///     next metadata request, and dropping the binding would leave the client unable to send or re-login.
+    /// </summary>
+    [Fact]
+    public void Encode_NamespaceFailureConsumesNothingAndKeepsTheSession()
+    {
+        var session = BoundSession();
+        var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4),
+            VsrTestPayloads.NumericIdentifier(5), null);
+        var header = new byte[VsrHeader.HEADER_SIZE];
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrHeader.EncodeRequestHeader(header, session, CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload));
+
+        Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode);
+        Assert.True(session.IsBound);
+        Assert.Equal(1UL, session.RequestCounter);
+
+        VsrHeader.EncodeRequestHeader(header, session, CommandCodes.CREATE_STREAM_CODE, [1]);
+
+        Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET));
+    }
+
+    [Fact]
+    public void Encode_RegisterUsesZeroRequestAndSessionOnMetadataNamespace()
+    {
+        var session = new ConsensusSession(7);
+        var payload = LoginRegister.Serialize("admin", "secret");
+
+        var header = Encode(session, CommandCodes.LOGIN_REGISTER_CODE, payload, out var totalSize);
+
+        Assert.Equal(VsrHeader.HEADER_SIZE + payload.Length, totalSize);
+        Assert.Equal((uint)totalSize, ReadUInt32(header, VsrHeader.SIZE_OFFSET));
+        Assert.Equal((byte)Command2.Request, header[VsrHeader.COMMAND_OFFSET]);
+        Assert.Equal((byte)VsrOperation.Register, header[VsrHeader.REQUEST_OPERATION_OFFSET]);
+        Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET));
+        Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_SESSION_OFFSET));
+        Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_TIMESTAMP_OFFSET));
+        Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET));
+        Assert.Equal(7UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET));
+        Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET + 8));
+    }
+
+    [Fact]
+    public void Encode_WritesClientIdAsTwoLittleEndianHalvesLowFirst()
+    {
+        var session = new ConsensusSession(new UInt128(0xAABB_CCDD_EEFF_0011, 0x1122_3344_5566_7788));
+
+        var header = Encode(session, CommandCodes.PING_CODE, [], out _);
+
+        Assert.Equal(0x1122_3344_5566_7788UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET));
+        Assert.Equal(0xAABB_CCDD_EEFF_0011UL, ReadUInt64(header, VsrHeader.REQUEST_CLIENT_OFFSET + 8));
+    }
+
+    [Fact]
+    public void Encode_NonReplicatedDoesNotAdvanceCounterAndCarriesCodeInReserved()
+    {
+        var session = BoundSession();
+
+        var header = Encode(session, CommandCodes.PING_CODE, [], out _);
+
+        Assert.Equal((byte)VsrOperation.NonReplicated, header[VsrHeader.REQUEST_OPERATION_OFFSET]);
+        Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET));
+        Assert.Equal(1UL, session.RequestCounter);
+        Assert.Equal(5UL, ReadUInt64(header, VsrHeader.REQUEST_SESSION_OFFSET));
+        Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET));
+        Assert.Equal((uint)CommandCodes.PING_CODE, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET));
+    }
+
+    [Fact]
+    public void Encode_UnknownCodeRidesNonReplicated()
+    {
+        var session = BoundSession();
+
+        var header = Encode(session, 9999, [], out _);
+
+        Assert.Equal((byte)VsrOperation.NonReplicated, header[VsrHeader.REQUEST_OPERATION_OFFSET]);
+        Assert.Equal(9999u, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET));
+    }
+
+    [Fact]
+    public void Encode_NonReplicatedWithoutSessionSendsSessionZero()
+    {
+        var session = new ConsensusSession(1);
+
+        var header = Encode(session, CommandCodes.PING_CODE, [], out _);
+
+        Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_SESSION_OFFSET));
+    }
+
+    [Fact]
+    public void Encode_MetadataAdvancesTheCounter()
+    {
+        var session = BoundSession();
+
+        var header = Encode(session, CommandCodes.CREATE_STREAM_CODE, [1, 2, 3], out _);
+
+        Assert.Equal((byte)VsrOperation.CreateStream, header[VsrHeader.REQUEST_OPERATION_OFFSET]);
+        Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET));
+        Assert.Equal(2UL, session.RequestCounter);
+        Assert.Equal(0UL, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET));
+        Assert.Equal(0u, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET));
+    }
+
+    [Fact]
+    public void Encode_LogoutAdvancesTheCounterOnMetadataNamespace()
+    {
+        var session = BoundSession();
+
+        var header = Encode(session, CommandCodes.LOGOUT_USER_CODE, [], out _);
+
+        Assert.Equal((byte)VsrOperation.Logout, header[VsrHeader.REQUEST_OPERATION_OFFSET]);
+        Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET));
+        Assert.Equal(2UL, session.RequestCounter);
+        Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE, ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET));
+    }
+
+    [Fact]
+    public void Encode_PartitionOpDoesNotAdvanceTheCounter()
+    {
+        var session = BoundSession();
+        var payload = VsrTestPayloads.SendMessagesToPartition(2, 3, 4);
+
+        var header = Encode(session, CommandCodes.SEND_MESSAGES_CODE, payload, out _);
+
+        Assert.Equal((byte)VsrOperation.SendMessages, header[VsrHeader.REQUEST_OPERATION_OFFSET]);
+        Assert.Equal(1UL, ReadUInt64(header, VsrHeader.REQUEST_ID_OFFSET));
+        Assert.Equal(1UL, session.RequestCounter);
+        Assert.Equal(VsrNamespace.Pack(2, 3, 4), ReadUInt64(header, VsrHeader.REQUEST_NAMESPACE_OFFSET));
+    }
+
+    [Fact]
+    public void Encode_ReplicatedOpWithoutSessionIsUnauthenticated()
+    {
+        var session = new ConsensusSession(1);
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            Encode(session, CommandCodes.CREATE_STREAM_CODE, [1], out _));
+
+        Assert.Equal(VsrError.UNAUTHENTICATED, exception.StatusCode);
+        Assert.Equal(1UL, session.RequestCounter);
+    }
+
+    [Fact]
+    public void Encode_ClearsStaleBytesFromAReusedBuffer()
+    {
+        var header = new byte[VsrHeader.HEADER_SIZE];
+        Array.Fill(header, (byte)0xFF);
+        var session = BoundSession();
+
+        VsrHeader.EncodeRequestHeader(header, session, CommandCodes.CREATE_STREAM_CODE, [1]);
+
+        Assert.Equal(0u, ReadUInt32(header, VsrHeader.REQUEST_RESERVED_OFFSET));
+        Assert.All(header[..VsrHeader.SIZE_OFFSET], stale => Assert.Equal(0, stale));
+    }
+
+    [Fact]
+    public void Encode_RejectsAShortBuffer()
+    {
+        var session = BoundSession();
+
+        Assert.Throws<ArgumentException>(() =>
+            VsrHeader.EncodeRequestHeader(new byte[VsrHeader.HEADER_SIZE - 1], session, CommandCodes.PING_CODE, []));
+    }
+
+    [Fact]
+    public void PeekCommand_MapsOnlyTheClientVisibleFrames()
+    {
+        var header = new byte[VsrHeader.HEADER_SIZE];
+
+        header[VsrHeader.COMMAND_OFFSET] = (byte)Command2.Reply;
+        Assert.Equal(Command2.Reply, VsrHeader.PeekCommand(header));
+
+        header[VsrHeader.COMMAND_OFFSET] = (byte)Command2.Eviction;
+        Assert.Equal(Command2.Eviction, VsrHeader.PeekCommand(header));
+
+        header[VsrHeader.COMMAND_OFFSET] = 6;
+        Assert.Equal(Command2.Reserved, VsrHeader.PeekCommand(header));
+    }
+
+    [Fact]
+    public void ReadReplyOperation_RejectsAnUnknownDiscriminant()
+    {
+        var header = new byte[VsrHeader.HEADER_SIZE];
+        header[VsrHeader.REPLY_OPERATION_OFFSET] = 200;
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() => VsrHeader.ReadReplyOperation(header));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode);
+    }
+
+    [Fact]
+    public void ReadEviction_ReadsReasonAndProtocolWindow()
+    {
+        var header = new byte[VsrHeader.HEADER_SIZE];
+        header[VsrHeader.EVICTION_REASON_OFFSET] = (byte)EvictionReason.IncompatibleProtocol;
+        BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.EVICTION_PROTOCOL_VERSION_OFFSET), 10243);
+        BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(VsrHeader.EVICTION_PROTOCOL_VERSION_MIN_OFFSET), 10240);
+
+        var eviction = VsrHeader.ReadEviction(header);
+
+        Assert.Equal(EvictionReason.IncompatibleProtocol, eviction.Reason);
+        Assert.Equal(10243u, eviction.ServerProtocolVersion);
+        Assert.Equal(10240u, eviction.ServerProtocolVersionMin);
+    }
+
+    /// <summary>
+    ///     An undecodable reason stays null rather than collapsing onto a named one, and grades through the
+    ///     shared grader's catch-all like every reason the Rust mapping does not name.
+    /// </summary>
+    [Fact]
+    public void ReadEviction_UnknownReasonDecodesAsUndecodable()
+    {
+        var header = new byte[VsrHeader.HEADER_SIZE];
+        header[VsrHeader.EVICTION_REASON_OFFSET] = 200;
+
+        Assert.Null(VsrHeader.ReadEviction(header).Reason);
+        Assert.Equal(VsrError.INVALID_COMMAND,
+            Assert.IsType<IggyInvalidStatusCodeException>(VsrReplyDecoder.ToException(VsrHeader.ReadEviction(header)))
+                .StatusCode);
+    }
+
+    /// <summary>
+    ///     Reason 0 is the Reserved sentinel the server rejects on the wire, so it decodes as an unrecognized
+    ///     reason rather than as one. Rust grades it through the same catch-all.
+    /// </summary>
+    [Fact]
+    public void ReadEviction_ReservedReasonDecodesAsUnknown()
+    {
+        var header = new byte[VsrHeader.HEADER_SIZE];
+        header[VsrHeader.EVICTION_REASON_OFFSET] = (byte)EvictionReason.Reserved;
+
+        Assert.Null(VsrHeader.ReadEviction(header).Reason);
+        Assert.Equal(VsrError.INVALID_COMMAND,
+            Assert.IsType<IggyInvalidStatusCodeException>(VsrReplyDecoder.ToException(VsrHeader.ReadEviction(header)))
+                .StatusCode);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrNamespaceTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrNamespaceTests.cs
new file mode 100644
index 000000000..3ce709560
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrNamespaceTests.cs
@@ -0,0 +1,282 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+public sealed class VsrNamespaceTests
+{
+    [Fact]
+    public void Pack_PlacesEachFieldAtItsShift()
+    {
+        Assert.Equal((2UL << 32) | (3UL << 20) | 4UL, VsrNamespace.Pack(2, 3, 4));
+    }
+
+    [Fact]
+    public void MetadataSentinel_SitsAboveThePackedRange()
+    {
+        var packedMax = VsrNamespace.Pack(VsrNamespace.MAX_STREAMS - 1, VsrNamespace.MAX_TOPICS - 1,
+            VsrNamespace.MAX_PARTITIONS - 1);
+
+        Assert.True(VsrNamespace.METADATA_CONSENSUS_NAMESPACE > packedMax);
+    }
+
+    [Theory]
+    [InlineData((byte)VsrOperation.Register)]
+    [InlineData((byte)VsrOperation.Logout)]
+    public void ForRequest_ControlPlaneOpsTargetTheMetadataReplica(byte operation)
+    {
+        Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE,
+            VsrNamespace.ForRequest(CommandCodes.LOGOUT_USER_CODE, [], (VsrOperation)operation));
+    }
+
+    [Fact]
+    public void ForRequest_MetadataAndNonReplicatedOpsUseZero()
+    {
+        Assert.Equal(0UL, VsrNamespace.ForRequest(CommandCodes.CREATE_STREAM_CODE, [], VsrOperation.CreateStream));
+        Assert.Equal(0UL, VsrNamespace.ForRequest(CommandCodes.PING_CODE, [], VsrOperation.NonReplicated));
+    }
+
+    [Fact]
+    public void ForRequest_SendMessagesPacksThePartitionId()
+    {
+        var payload = VsrTestPayloads.SendMessagesToPartition(1, 2, 3);
+
+        Assert.Equal(VsrNamespace.Pack(1, 2, 3),
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+    }
+
+    [Fact]
+    public void ForRequest_SendMessagesIgnoresTheMessageBatchAfterTheMetadata()
+    {
+        var payload = VsrTestPayloads.Concat(VsrTestPayloads.SendMessagesToPartition(1, 2, 3), [0xFF, 0xFF, 0xFF]);
+
+        Assert.Equal(VsrNamespace.Pack(1, 2, 3),
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+    }
+
+    [Theory]
+    [InlineData(1)]
+    [InlineData(3)]
+    public void ForRequest_SendMessagesRejectsServerSidePartitioning(byte kind)
+    {
+        var payload = VsrTestPayloads.SendMessages(VsrTestPayloads.NumericIdentifier(1),
+            VsrTestPayloads.NumericIdentifier(2), kind, kind == 1 ? [] : "key"u8.ToArray());
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+
+        Assert.Equal(VsrError.FEATURE_UNAVAILABLE, exception.StatusCode);
+    }
+
+    [Fact]
+    public void ForRequest_NamedIdentifiersResolveToZero()
+    {
+        var payload = VsrTestPayloads.SendMessages(VsrTestPayloads.NamedIdentifier("stream"),
+            VsrTestPayloads.NumericIdentifier(2), 2, VsrTestPayloads.UInt32(3));
+
+        Assert.Equal(0UL,
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+    }
+
+    [Theory]
+    [InlineData(1, 3)]
+    [InlineData(2, 0)]
+    [InlineData(7, 4)]
+    public void ForRequest_MalformedIdentifierIsInvalidCommandNotANamedIdentifier(byte kind, byte length)
+    {
+        var streamId = VsrTestPayloads.Concat([kind, length], new byte[length]);
+        var payload = VsrTestPayloads.SendMessages(streamId, VsrTestPayloads.NumericIdentifier(2), 2,
+            VsrTestPayloads.UInt32(3));
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode);
+    }
+
+    [Fact]
+    public void ForRequest_UnknownConsumerKindIsInvalidCommand()
+    {
+        var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4),
+            VsrTestPayloads.NumericIdentifier(5), 6);
+        payload[0] = 7;
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload,
+                VsrOperation.StoreConsumerOffset));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode);
+    }
+
+    [Theory]
+    [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.StoreConsumerOffset)]
+    [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.DeleteConsumerOffset)]
+    [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.StoreConsumerOffset2)]
+    [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.DeleteConsumerOffset2)]
+    public void ForRequest_ConsumerOffsetOpsPackTheirPartition(int code, byte operation)
+    {
+        var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4),
+            VsrTestPayloads.NumericIdentifier(5), 6);
+
+        Assert.Equal(VsrNamespace.Pack(4, 5, 6), VsrNamespace.ForRequest(code, payload, (VsrOperation)operation));
+    }
+
+    [Fact]
+    public void ForRequest_ConsumerOffsetWithoutAPartitionIsRejected()
+    {
+        var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4),
+            VsrTestPayloads.NumericIdentifier(5), null);
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload,
+                VsrOperation.StoreConsumerOffset));
+
+        Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode);
+    }
+
+    /// <summary>
+    ///     The partition id is missing, not truncated, so the caller learns the request cannot be routed rather
+    ///     than that its payload is malformed - the length guard must not pre-empt that.
+    /// </summary>
+    [Fact]
+    public void ForRequest_ConsumerOffsetWithoutAPartitionIsRejectedEvenWhenTheBodyEndsThere()
+    {
+        byte[] full = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4),
+            VsrTestPayloads.NumericIdentifier(5), null);
+
+        // Drop the four padding bytes that follow the absent-partition flag.
+        var payload = full.AsSpan(0, full.Length - 4).ToArray();
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload,
+                VsrOperation.StoreConsumerOffset));
+
+        Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode);
+    }
+
+    /// <summary>
+    ///     A metadata length near int.MaxValue overflows when four is added to it, so the bounds check has to be
+    ///     written as a subtraction or it passes and the slice throws an untyped exception instead.
+    /// </summary>
+    [Fact]
+    public void ForRequest_SendMessagesMetadataLengthNearIntMaxIsInvalidCommand()
+    {
+        var payload = new byte[64];
+        BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)int.MaxValue - 2);
+
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode);
+    }
+
+    [Fact]
+    public void ForRequest_DeleteSegmentsPacksThePartition()
+    {
+        var payload = VsrTestPayloads.DeleteSegments(VsrTestPayloads.NumericIdentifier(7),
+            VsrTestPayloads.NumericIdentifier(8), 9);
+
+        Assert.Equal(VsrNamespace.Pack(7, 8, 9),
+            VsrNamespace.ForRequest(CommandCodes.DELETE_SEGMENTS_CODE, payload, VsrOperation.DeleteSegments));
+    }
+
+    [Theory]
+    [InlineData(VsrNamespace.MAX_STREAMS, 1, 1)]
+    [InlineData(1, VsrNamespace.MAX_TOPICS, 1)]
+    [InlineData(1, 1, VsrNamespace.MAX_PARTITIONS)]
+    public void ForRequest_RejectsIdentifiersOutsideThePackableRange(uint streamId, uint topicId, uint partitionId)
+    {
+        var payload = VsrTestPayloads.SendMessagesToPartition(streamId, topicId, partitionId);
+
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+
+        Assert.Equal(VsrError.INVALID_IDENTIFIER, exception.StatusCode);
+    }
+
+    /// <summary>A partition id is four bytes; anything else means the caller framed a different value.</summary>
+    [Theory]
+    [InlineData(2)]
+    [InlineData(8)]
+    public void ForRequest_SendMessagesRejectsAPartitionIdOfTheWrongWidth(byte length)
+    {
+        var payload = VsrTestPayloads.SendMessages(VsrTestPayloads.NumericIdentifier(1),
+            VsrTestPayloads.NumericIdentifier(2), 2, new byte[length]);
+
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload, VsrOperation.SendMessages));
+        Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode);
+    }
+
+    [Fact]
+    public void ForRequest_TruncatedSendMessagesPayloadIsInvalidCommand()
+    {
+        var payload = VsrTestPayloads.SendMessagesToPartition(1, 2, 3);
+        for (var length = 0; length < payload.Length; length++)
+        {
+            var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+                VsrNamespace.ForRequest(CommandCodes.SEND_MESSAGES_CODE, payload.AsSpan(0, length).ToArray(),
+                    VsrOperation.SendMessages));
+
+            Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode);
+        }
+    }
+
+    [Fact]
+    public void ForRequest_TruncatedConsumerOffsetPayloadIsInvalidCommand()
+    {
+        var payload = VsrTestPayloads.ConsumerOffset(VsrTestPayloads.NumericIdentifier(4),
+            VsrTestPayloads.NumericIdentifier(5), 6);
+        for (var length = 0; length < payload.Length; length++)
+        {
+            var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+                VsrNamespace.ForRequest(CommandCodes.STORE_CONSUMER_OFFSET_CODE, payload.AsSpan(0, length).ToArray(),
+                    VsrOperation.StoreConsumerOffset));
+
+            Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode);
+        }
+    }
+
+    [Fact]
+    public void ForRequest_TruncatedDeleteSegmentsPayloadIsInvalidCommand()
+    {
+        var payload = VsrTestPayloads.DeleteSegments(VsrTestPayloads.NumericIdentifier(7),
+            VsrTestPayloads.NumericIdentifier(8), 9);
+        for (var length = 0; length < payload.Length - 4; length++)
+        {
+            var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+                VsrNamespace.ForRequest(CommandCodes.DELETE_SEGMENTS_CODE, payload.AsSpan(0, length).ToArray(),
+                    VsrOperation.DeleteSegments));
+
+            Assert.Equal(VsrError.INVALID_COMMAND, exception.StatusCode);
+        }
+    }
+
+    [Fact]
+    public void ForRequest_UnroutablePartitionCodeIsFeatureUnavailable()
+    {
+        var exception = Assert.Throws<IggyInvalidStatusCodeException>(() =>
+            VsrNamespace.ForRequest(CommandCodes.POLL_MESSAGES_CODE, [], VsrOperation.SendMessages));
+
+        Assert.Equal(VsrError.FEATURE_UNAVAILABLE, exception.StatusCode);
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrOperationTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrOperationTests.cs
new file mode 100644
index 000000000..bd5686e03
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrOperationTests.cs
@@ -0,0 +1,167 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using Apache.Iggy.Utils;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+public sealed class VsrOperationTests
+{
+    [Theory]
+    [InlineData(CommandCodes.LOGOUT_USER_CODE, (byte)VsrOperation.Logout)]
+    [InlineData(CommandCodes.CREATE_USER_CODE, (byte)VsrOperation.CreateUser)]
+    [InlineData(CommandCodes.DELETE_USER_CODE, (byte)VsrOperation.DeleteUser)]
+    [InlineData(CommandCodes.UPDATE_USER_CODE, (byte)VsrOperation.UpdateUser)]
+    [InlineData(CommandCodes.UPDATE_PERMISSIONS_CODE, (byte)VsrOperation.UpdatePermissions)]
+    [InlineData(CommandCodes.CHANGE_PASSWORD_CODE, (byte)VsrOperation.ChangePassword)]
+    [InlineData(CommandCodes.CREATE_PERSONAL_ACCESS_TOKEN_CODE, (byte)VsrOperation.CreatePersonalAccessToken)]
+    [InlineData(CommandCodes.DELETE_PERSONAL_ACCESS_TOKEN_CODE, (byte)VsrOperation.DeletePersonalAccessToken)]
+    [InlineData(CommandCodes.SEND_MESSAGES_CODE, (byte)VsrOperation.SendMessages)]
+    [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.StoreConsumerOffset)]
+    [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_CODE, (byte)VsrOperation.DeleteConsumerOffset)]
+    [InlineData(CommandCodes.STORE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.StoreConsumerOffset2)]
+    [InlineData(CommandCodes.DELETE_CONSUMER_OFFSET_2_CODE, (byte)VsrOperation.DeleteConsumerOffset2)]
+    [InlineData(CommandCodes.CREATE_STREAM_CODE, (byte)VsrOperation.CreateStream)]
+    [InlineData(CommandCodes.DELETE_STREAM_CODE, (byte)VsrOperation.DeleteStream)]
+    [InlineData(CommandCodes.UPDATE_STREAM_CODE, (byte)VsrOperation.UpdateStream)]
+    [InlineData(CommandCodes.PURGE_STREAM_CODE, (byte)VsrOperation.PurgeStream)]
+    [InlineData(CommandCodes.CREATE_TOPIC_CODE, (byte)VsrOperation.CreateTopic)]
+    [InlineData(CommandCodes.DELETE_TOPIC_CODE, (byte)VsrOperation.DeleteTopic)]
+    [InlineData(CommandCodes.UPDATE_TOPIC_CODE, (byte)VsrOperation.UpdateTopic)]
+    [InlineData(CommandCodes.PURGE_TOPIC_CODE, (byte)VsrOperation.PurgeTopic)]
+    [InlineData(CommandCodes.CREATE_PARTITIONS_CODE, (byte)VsrOperation.CreatePartitions)]
+    [InlineData(CommandCodes.DELETE_PARTITIONS_CODE, (byte)VsrOperation.DeletePartitions)]
+    [InlineData(CommandCodes.DELETE_SEGMENTS_CODE, (byte)VsrOperation.DeleteSegments)]
+    [InlineData(CommandCodes.CREATE_CONSUMER_GROUP_CODE, (byte)VsrOperation.CreateConsumerGroup)]
+    [InlineData(CommandCodes.DELETE_CONSUMER_GROUP_CODE, (byte)VsrOperation.DeleteConsumerGroup)]
+    [InlineData(CommandCodes.JOIN_CONSUMER_GROUP_CODE, (byte)VsrOperation.JoinConsumerGroup)]
+    [InlineData(CommandCodes.LEAVE_CONSUMER_GROUP_CODE, (byte)VsrOperation.LeaveConsumerGroup)]
+    public void ForCode_MapsReplicatedCommands(int code, byte expected)
+    {
+        Assert.Equal((VsrOperation)expected, VsrOperations.ForCode(code));
+    }
+
+    [Theory]
+    [InlineData(CommandCodes.PING_CODE)]
+    [InlineData(CommandCodes.POLL_MESSAGES_CODE)]
+    [InlineData(CommandCodes.GET_STREAM_CODE)]
+    [InlineData(CommandCodes.GET_CONSUMER_OFFSET_CODE)]
+    [InlineData(9999)]
+    public void ForCode_ReadsAndUnknownCodesRideNonReplicated(int code)
+    {
+        Assert.Equal(VsrOperation.NonReplicated, VsrOperations.ForCode(code));
+    }
+
+    [Theory]
+    [InlineData(CommandCodes.LOGIN_REGISTER_CODE)]
+    [InlineData(CommandCodes.LOGIN_REGISTER_WITH_PAT_CODE)]
+    public void ForCode_MapsTheRegisterHandshake(int code)
+    {
+        Assert.Equal(VsrOperation.Register, VsrOperations.ForCode(code));
+    }
+
+    /// <summary>
+    ///     A classic login sent as a consensus request would ride NonReplicated and look like it worked while no
+    ///     session is ever bound, so the classification rejects it instead.
+    /// </summary>
+    [Theory]
+    [InlineData(CommandCodes.LOGIN_USER_CODE)]
+    [InlineData(CommandCodes.LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE)]
+    public void ForCode_RejectsLoginCodes(int code)
+    {
+        var error = Assert.Throws<IggyInvalidStatusCodeException>(() => VsrOperations.ForCode(code));
+
+        Assert.Equal(VsrError.INVALID_COMMAND, error.StatusCode);
+    }
+
+    [Fact]
+    public void Classification_MatchesTheServerSideRanges()
+    {
+        Assert.True(VsrOperation.CreateTopicWithAssignments.IsInternal());
+        Assert.True(VsrOperation.CreateTopicWithAssignments.IsMetadata());
+        Assert.True(VsrOperation.CreateStream.IsMetadata());
+        Assert.False(VsrOperation.CreateStream.IsPartition());
+        Assert.True(VsrOperation.SendMessages.IsPartition());
+        Assert.False(VsrOperation.SendMessages.IsMetadata());
+
+        // Resolved server-side to an internal truncate, so it is neither plane despite carrying a namespace.
+        Assert.False(VsrOperation.DeleteSegments.IsPartition());
+        Assert.False(VsrOperation.DeleteSegments.IsMetadata());
+    }
+
+    [Fact]
+    public void IsResultFramed_CoversMetadataAndConsumerOffsetsOnly()
+    {
+        Assert.True(VsrOperation.CreateStream.IsResultFramed());
+        Assert.True(VsrOperation.StoreConsumerOffset.IsResultFramed());
+        Assert.True(VsrOperation.DeleteConsumerOffset2.IsResultFramed());
+        Assert.False(VsrOperation.SendMessages.IsResultFramed());
+        Assert.False(VsrOperation.NonReplicated.IsResultFramed());
+        Assert.False(VsrOperation.Register.IsResultFramed());
+        Assert.False(VsrOperation.Logout.IsResultFramed());
+    }
+
+    [Fact]
+    public void IsKnown_RejectsUndefinedDiscriminants()
+    {
+        Assert.True(VsrOperations.IsKnown((byte)VsrOperation.SendMessages));
+        Assert.False(VsrOperations.IsKnown(163));
+        Assert.False(VsrOperations.IsKnown(200));
+    }
+
+    /// <summary>Every arm of the control-plane table: shard 0 owns these, so a miss would route to a data shard.</summary>
+    [Theory]
+    [InlineData((byte)VsrOperation.CreateStream)]
+    [InlineData((byte)VsrOperation.UpdateStream)]
+    [InlineData((byte)VsrOperation.DeleteStream)]
+    [InlineData((byte)VsrOperation.PurgeStream)]
+    [InlineData((byte)VsrOperation.CreateTopic)]
+    [InlineData((byte)VsrOperation.UpdateTopic)]
+    [InlineData((byte)VsrOperation.DeleteTopic)]
+    [InlineData((byte)VsrOperation.PurgeTopic)]
+    [InlineData((byte)VsrOperation.CreatePartitions)]
+    [InlineData((byte)VsrOperation.DeletePartitions)]
+    [InlineData((byte)VsrOperation.CreateConsumerGroup)]
+    [InlineData((byte)VsrOperation.DeleteConsumerGroup)]
+    [InlineData((byte)VsrOperation.CreateUser)]
+    [InlineData((byte)VsrOperation.UpdateUser)]
+    [InlineData((byte)VsrOperation.DeleteUser)]
+    [InlineData((byte)VsrOperation.ChangePassword)]
+    [InlineData((byte)VsrOperation.UpdatePermissions)]
+    [InlineData((byte)VsrOperation.CreatePersonalAccessToken)]
+    [InlineData((byte)VsrOperation.DeletePersonalAccessToken)]
+    [InlineData((byte)VsrOperation.JoinConsumerGroup)]
+    [InlineData((byte)VsrOperation.LeaveConsumerGroup)]
+    public void IsMetadata_CoversTheWholeControlPlane(byte operation)
+    {
+        Assert.True(((VsrOperation)operation).IsMetadata());
+    }
+
+    [Theory]
+    [InlineData((byte)VsrOperation.SendMessages)]
+    [InlineData((byte)VsrOperation.StoreConsumerOffset)]
+    [InlineData((byte)VsrOperation.DeleteConsumerOffset2)]
+    [InlineData((byte)VsrOperation.DeleteSegments)]
+    [InlineData((byte)VsrOperation.NonReplicated)]
+    [InlineData((byte)VsrOperation.Logout)]
+    public void IsMetadata_LeavesTheDataPlaneOut(byte operation)
+    {
+        Assert.False(((VsrOperation)operation).IsMetadata());
+    }
+}
diff --git a/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrProtocolDriftTests.cs b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrProtocolDriftTests.cs
new file mode 100644
index 000000000..2f7db548d
--- /dev/null
+++ b/foreign/csharp/Iggy_SDK_Tests/VsrTests/VsrProtocolDriftTests.cs
@@ -0,0 +1,849 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Apache.Iggy.Exceptions;
+using Apache.Iggy.Utils;
+using Apache.Iggy.Vsr;
+
+namespace Apache.Iggy.Tests.VsrTests;
+
+/// <summary>
+///     Asserts the .NET VSR constants still match the Rust wire definitions they were ported from. The unit
+///     tests above pin the .NET side against itself; this one pins it against
+///     <c>core/binary_protocol/</c>, so a Rust-side change fails here instead of in production framing.
+///     Skipped when the Rust sources are not on disk (packaged SDK builds).
+/// </summary>
+public sealed class VsrProtocolDriftTests
+{
+    private const string HeaderPath = "core/binary_protocol/src/consensus/header.rs";
+    private const string CommandPath = "core/binary_protocol/src/consensus/command.rs";
+    private const string OperationPath = "core/binary_protocol/src/consensus/operation.rs";
+    private const string NamespacePath = "core/binary_protocol/src/namespace.rs";
+    private const string CodesPath = "core/binary_protocol/src/codes.rs";
+    private const string DispatchPath = "core/binary_protocol/src/dispatch.rs";
+    private const string ErrorPath = "core/common/src/error/iggy_error.rs";
+    private const string ManifestPath = "core/binary_protocol/Cargo.toml";
+    private const string EvictionGraderPath = "core/common/src/error/eviction.rs";
+    private const string ReplyResultPath = "core/binary_protocol/src/consensus/reply_result.rs";
+    private const string CredentialDefaultsPath = "core/common/src/http/users/defaults.rs";
+
+    private const string LoginRegisterResponsePath =
+        "core/binary_protocol/src/responses/users/login_register.rs";
+
+    private const string SyncConsumerGroupResponsePath =
+        "core/binary_protocol/src/responses/consumer_groups/sync_consumer_group.rs";
+
+    /// <summary>
+    ///     Codes the SDK deliberately does not resolve the way the Rust dispatch table declares them, with the
+    ///     reason each one is exempt. Anything not listed here must agree with the table.
+    /// </summary>
+    private static readonly Dictionary<string, VsrOperation?> CodeMappingExceptions = new()
+    {
+        // Non-replicated in the table because the legacy transport routes them; under VSR they are the
+        // consensus handshake itself and carry their own operations.
+        ["LOGIN_REGISTER_CODE"] = VsrOperation.Register,
+        ["LOGIN_REGISTER_WITH_PAT_CODE"] = VsrOperation.Register,
+        ["LOGOUT_USER_CODE"] = VsrOperation.Logout,
+
+        // VSR has no legacy login. ForCode throws rather than encoding a request that would look like a
+        // working login while binding no session; null means "asserted to throw".
+        ["LOGIN_USER_CODE"] = null,
+        ["LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE"] = null
+    };
+
+    [Fact]
+    public void RequestHeaderOffsets_MatchTheRustLayout()
+    {
+        IReadOnlyDictionary<string, int> offsets = RustStruct.Offsets(ReadRustSource(HeaderPath), "RequestHeader");
+
+        Assert.Equal(VsrHeader.SIZE_OFFSET, offsets["size"]);
+        Assert.Equal(VsrHeader.COMMAND_OFFSET, offsets["command"]);
+        Assert.Equal(VsrHeader.REQUEST_CLIENT_OFFSET, offsets["client"]);
+        Assert.Equal(VsrHeader.REQUEST_TIMESTAMP_OFFSET, offsets["timestamp"]);
+        Assert.Equal(VsrHeader.REQUEST_ID_OFFSET, offsets["request"]);
+        Assert.Equal(VsrHeader.REQUEST_OPERATION_OFFSET, offsets["operation"]);
+        Assert.Equal(VsrHeader.REQUEST_NAMESPACE_OFFSET, offsets["namespace"]);
+        Assert.Equal(VsrHeader.REQUEST_SESSION_OFFSET, offsets["session"]);
+        Assert.Equal(VsrHeader.REQUEST_RESERVED_OFFSET, offsets["reserved"]);
+    }
+
+    [Fact]
+    public void ReplyHeaderOffsets_MatchTheRustLayout()
+    {
+        IReadOnlyDictionary<string, int> offsets = RustStruct.Offsets(ReadRustSource(HeaderPath), "ReplyHeader");
+
+        Assert.Equal(VsrHeader.SIZE_OFFSET, offsets["size"]);
+        Assert.Equal(VsrHeader.COMMAND_OFFSET, offsets["command"]);
+        Assert.Equal(VsrHeader.REPLY_OPERATION_OFFSET, offsets["operation"]);
+        Assert.Equal(VsrHeader.REPLY_NAMESPACE_OFFSET, offsets["namespace"]);
+        Assert.Equal(VsrHeader.REPLY_STATUS_OFFSET, offsets["status"]);
+    }
+
+    [Fact]
+    public void EvictionHeaderOffsets_MatchTheRustLayout()
+    {
+        IReadOnlyDictionary<string, int> offsets = RustStruct.Offsets(ReadRustSource(HeaderPath), "EvictionHeader");
+
+        Assert.Equal(VsrHeader.COMMAND_OFFSET, offsets["command"]);
+        Assert.Equal(VsrHeader.EVICTION_CLIENT_OFFSET, offsets["client"]);
+        Assert.Equal(VsrHeader.EVICTION_PROTOCOL_VERSION_OFFSET, offsets["server_protocol_version"]);
+        Assert.Equal(VsrHeader.EVICTION_PROTOCOL_VERSION_MIN_OFFSET, offsets["server_protocol_version_min"]);
+        Assert.Equal(VsrHeader.EVICTION_REASON_OFFSET, offsets["reason"]);
+    }
+
+    [Fact]
+    public void HeaderSize_MatchesTheRustConstant()
+    {
+        var source = ReadRustSource(HeaderPath);
+        var declared = Regex.Match(source, @"pub const HEADER_SIZE: usize = (\d+);");
+
+        Assert.True(declared.Success, "HEADER_SIZE is no longer declared in header.rs.");
+        Assert.Equal(VsrHeader.HEADER_SIZE, int.Parse(declared.Groups[1].Value, CultureInfo.InvariantCulture));
+        Assert.Equal(VsrHeader.HEADER_SIZE, RustStruct.Size(source, "RequestHeader"));
+        Assert.Equal(VsrHeader.HEADER_SIZE, RustStruct.Size(source, "ReplyHeader"));
+        Assert.Equal(VsrHeader.HEADER_SIZE, RustStruct.Size(source, "EvictionHeader"));
+    }
+
+    [Fact]
+    public void Command2Discriminants_MatchTheRustEnum()
+    {
+        IReadOnlyDictionary<string, int> rust = RustEnum.Discriminants(ReadRustSource(CommandPath), "Command2");
+
+        AssertSubsetMatches<Command2>(rust);
+    }
+
+    [Fact]
+    public void EvictionReasons_MatchTheRustEnumExactly()
+    {
+        IReadOnlyDictionary<string, int> rust = RustEnum.Discriminants(ReadRustSource(HeaderPath), "EvictionReason");
+
+        AssertExactMatch<EvictionReason>(rust);
+    }
+
+    [Fact]
+    public void Operations_MatchTheRustEnumExactly()
+    {
+        IReadOnlyDictionary<string, int> rust = RustEnum.Discriminants(ReadRustSource(OperationPath), "Operation");
+
+        AssertExactMatch<VsrOperation>(rust);
+    }
+
+    [Fact]
+    public void NamespacePacking_MatchesTheRustConstants()
+    {
+        var source = ReadRustSource(NamespacePath);
+
+        Assert.Equal(VsrNamespace.MAX_STREAMS, RustConst(source, "MAX_STREAMS"));
+        Assert.Equal(VsrNamespace.MAX_TOPICS, RustConst(source, "MAX_TOPICS"));
+        Assert.Equal(VsrNamespace.MAX_PARTITIONS, RustConst(source, "MAX_PARTITIONS"));
+        Assert.Equal(VsrNamespace.PARTITION_SHIFT, RustConst(source, "PARTITION_SHIFT"));
+
+        // The remaining shifts are derived from the maxima on the Rust side, so re-derive them the same way
+        // instead of matching an expression the parser would have to evaluate.
+        Assert.Equal(VsrNamespace.PARTITION_BITS, BitsRequired(VsrNamespace.MAX_PARTITIONS - 1));
+        Assert.Equal(VsrNamespace.TOPIC_BITS, BitsRequired(VsrNamespace.MAX_TOPICS - 1));
+        Assert.Equal(VsrNamespace.STREAM_BITS, BitsRequired(VsrNamespace.MAX_STREAMS - 1));
+        Assert.Equal(VsrNamespace.TOPIC_SHIFT, VsrNamespace.PARTITION_SHIFT + VsrNamespace.PARTITION_BITS);
+        Assert.Equal(VsrNamespace.STREAM_SHIFT, VsrNamespace.TOPIC_SHIFT + VsrNamespace.TOPIC_BITS);
+
+        var sentinel = Regex.Match(source, @"pub const METADATA_CONSENSUS_NAMESPACE: u64 = 1u64 << (\d+);");
+        Assert.True(sentinel.Success, "METADATA_CONSENSUS_NAMESPACE is no longer a shifted literal.");
+        Assert.Equal(VsrNamespace.METADATA_CONSENSUS_NAMESPACE,
+            1UL << int.Parse(sentinel.Groups[1].Value, CultureInfo.InvariantCulture));
+    }
+
+    [Fact]
+    public void ProtocolVersion_MatchesTheBinaryProtocolCrateVersion()
+    {
+        var manifest = ReadRustSource(ManifestPath);
+        var declared = Regex.Match(manifest, @"^version\s*=\s*""(\d+)\.(\d+)\.(\d+)", RegexOptions.Multiline);
+
+        Assert.True(declared.Success, "iggy_binary_protocol no longer declares a semver version.");
+        Assert.Equal(LoginRegister.PROTOCOL_VERSION_MAJOR, Group(declared, 1));
+        Assert.Equal(LoginRegister.PROTOCOL_VERSION_MINOR, Group(declared, 2));
+        Assert.Equal(LoginRegister.PROTOCOL_VERSION_PATCH, Group(declared, 3));
+    }
+
+    /// <summary>
+    ///     Pins <see cref="CommandCodes" /> against <c>codes.rs</c> as a set of values, the way the Node mirror
+    ///     does. A name-keyed lookup alone lets a command added upstream pass unnoticed, because the constant it
+    ///     would have to match does not exist here yet. Names are still compared where both sides declare them,
+    ///     which catches a renumber that keeps the set size intact.
+    /// </summary>
+    [Fact]
+    public void CommandCodes_MatchTheRustCodes()
+    {
+        var rust = Regex.Matches(ReadRustSource(CodesPath), @"pub const (\w+_CODE): u32 = (\d+);")
+            .ToDictionary(code => code.Groups[1].Value, code => Group(code, 2));
+        Assert.NotEmpty(rust);
+
+        var dotnet = typeof(CommandCodes)
+            .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static)
+            .Where(field => field is { IsLiteral: true, FieldType.Name: nameof(Int32) })
+            .ToDictionary(field => field.Name, field => (int)field.GetRawConstantValue()!);
+
+        var mismatches = rust
+            .Where(code => !dotnet.ContainsValue(code.Value))
+            .Select(code => $"{code.Key} = {code.Value} is declared in Rust and missing from CommandCodes")
+            .Concat(dotnet
+                .Where(code => !rust.ContainsValue(code.Value))
+                .Select(code => $"{code.Key} = {code.Value} is declared in CommandCodes and missing from Rust"))
+            .Concat(rust
+                .Where(code => dotnet.TryGetValue(code.Key, out var actual) && actual != code.Value)
+                .Select(code => $"{code.Key}: rust {code.Value}, .NET {dotnet[code.Key]}"))
+            .ToList();
+
+        Assert.Empty(mismatches);
+    }
+
+    /// <summary>
+    ///     Pins <see cref="VsrOperations.ForCode" /> against the Rust dispatch table. The .NET mapping is a hand
+    ///     written switch whose default arm is <see cref="VsrOperation.NonReplicated" />, so a command that gains
+    ///     replication upstream would otherwise keep encoding as non-replicated here: it would apply on the node
+    ///     that received it and never reach the others, diverging the replicas. This is the sweep
+    ///     <c>no_replicated_command_ever_resolves_to_non_replicated</c> performs on the Rust side.
+    /// </summary>
+    [Fact]
+    public void CommandOperationTable_MatchesTheRustDispatchTable()
+    {
+        var source = ReadRustSource(DispatchPath);
+
+        // Table entries wrap across lines once the arguments are long enough, so the separators have to match
+        // arbitrary whitespace rather than a single line.
+        var replicated = Regex.Matches(source,
+            @"CommandMeta::replicated\(\s*(\w+_CODE)\s*,\s*""[^""]*""\s*,\s*Operation::(\w+)\s*,?\s*\)");
+        var nonReplicated = Regex.Matches(source, @"CommandMeta::non_replicated\(\s*(\w+_CODE)\s*,");
+
+        Assert.NotEmpty(replicated);
+        Assert.NotEmpty(nonReplicated);
+
+        var mismatches = new List<string>();
+        var matched = 0;
+
+        foreach (Match entry in replicated)
+        {
+            var codeName = entry.Groups[1].Value;
+            if (CommandCode(codeName) is not { } code)
+            {
+                // Skipping here would exempt the exact command this test exists to catch: one that gains
+                // replication upstream while the .NET side has no code for it, so every call encodes as
+                // non-replicated and applies on one node only.
+                mismatches.Add($"{codeName}: rust replicates it, absent from .NET CommandCodes");
+
+                continue;
+            }
+
+            matched++;
+            if (!Enum.TryParse<VsrOperation>(entry.Groups[2].Value, out var expected))
+            {
+                mismatches.Add($"{codeName}: rust maps to Operation::{entry.Groups[2].Value}, absent from .NET");
+
+                continue;
+            }
+
+            var actual = ResolveOperation(codeName, code);
+            if (actual != expected)
+            {
+                mismatches.Add($"{codeName}: rust {expected}, .NET {ActualText(actual)}");
+            }
+        }
+
+        foreach (Match entry in nonReplicated)
+        {
+            var codeName = entry.Groups[1].Value;
+            if (CommandCode(codeName) is not { } code)
+            {
+                continue;
+            }
+
+            matched++;
+            var expected = CodeMappingExceptions.TryGetValue(codeName, out var exempt)
+                ? exempt
+                : VsrOperation.NonReplicated;
+            var actual = ResolveOperation(codeName, code);
+            if (actual != expected)
+            {
+                mismatches.Add($"{codeName}: expected {ActualText(expected)}, .NET {ActualText(actual)}");
+            }
+        }
+
+        Assert.Empty(mismatches);
+        Assert.True(matched > 40, $"Only {matched} dispatch entries were matched by name; the Rust naming drifted.");
+    }
+
+    /// <summary>
+    ///     Pins <see cref="VsrOperations.IsMetadata" /> against <c>Operation::is_metadata</c>. Metadata replies
+    ///     lead their body with a committed result section, so an operation misclassified here has its rejection
+    ///     entry decoded as payload and a refused command reads as a success.
+    /// </summary>
+    [Fact]
+    public void MetadataOperations_MatchTheRustClassifier()
+    {
+        var body = MatchesArmBody(ReadRustSource(OperationPath), "is_metadata");
+        var rust = Regex.Matches(body, @"Self::(\w+)").Select(match => match.Groups[1].Value).ToHashSet();
+        Assert.NotEmpty(rust);
+
+        var mismatches = new List<string>();
+        foreach (VsrOperation operation in Enum.GetValues<VsrOperation>())
+        {
+            // is_internal() short-circuits ahead of the match arm on both sides, so those members are metadata
+            // without appearing in the list.
+            var expected = rust.Contains(operation.ToString()) || operation.IsInternal();
+            if (operation.IsMetadata() != expected)
+            {
+                mismatches.Add($"{operation}: rust {expected}, .NET {operation.IsMetadata()}");
+            }
+        }
+
+        Assert.Empty(mismatches);
+    }
+
+    /// <summary>
+    ///     Pins <see cref="VsrOperations.IsResultFramed" /> against <c>Operation::is_result_framed</c>. The Rust
+    ///     side composes it from <c>is_metadata</c> plus its own partition-plane list, so pinning
+    ///     <c>is_metadata</c> alone leaves the second half free to drift. An operation that gains result framing
+    ///     upstream and not here has <see cref="VsrReplyDecoder.SplitResultSection" /> skip the strip, and a
+    ///     committed rejection decodes as payload: a refused command reads as a success.
+    /// </summary>
+    [Fact]
+    public void ResultFramedOperations_MatchTheRustClassifier()
+    {
+        var body = MatchesArmBody(ReadRustSource(OperationPath), "is_result_framed");
+        var rust = Regex.Matches(body, @"Self::(\w+)").Select(match => match.Groups[1].Value).ToHashSet();
+        Assert.NotEmpty(rust);
+
+        var mismatches = new List<string>();
+        foreach (VsrOperation operation in Enum.GetValues<VsrOperation>())
+        {
+            // The Rust body is `is_metadata() || matches!(...)`, so the metadata members carry over without
+            // appearing in the list.
+            var expected = rust.Contains(operation.ToString()) || operation.IsMetadata();
+            if (operation.IsResultFramed() != expected)
+            {
+                mismatches.Add($"{operation}: rust {expected}, .NET {operation.IsResultFramed()}");
+            }
+        }
+
+        Assert.Empty(mismatches);
+    }
+
+    /// <summary>
+    ///     Pins <see cref="VsrError" /> against the Rust error discriminants. These are copied by hand and drive
+    ///     more than error text: <c>TRANSIENT_NOT_COMMITTED</c> and <c>TRANSIENT_NOT_ACCEPTED</c> are what tells a
+    ///     same-session replay from a fresh-session reissue, so a renumber upstream would reissue a write whose
+    ///     outcome is unknown under a client id the server cannot deduplicate against, applying it twice.
+    /// </summary>
+    [Fact]
+    public void ErrorCodes_MatchTheRustDiscriminants()
+    {
+        var rust = Regex.Matches(ReadRustSource(ErrorPath), @"^\s{4}(\w+)(?:\([^)]*\))?\s*=\s*(\d+),",
+                RegexOptions.Multiline)
+            .ToDictionary(match => Normalize(match.Groups[1].Value),
+                match => int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture));
+        Assert.NotEmpty(rust);
+
+        var mismatches = new List<string>();
+        var matched = 0;
+
+        foreach (FieldInfo field in typeof(VsrError).GetFields(BindingFlags.NonPublic | BindingFlags.Static))
+        {
+            if (!field.IsLiteral || field.FieldType != typeof(int))
+            {
+                continue;
+            }
+
+            if (!rust.TryGetValue(Normalize(field.Name), out var expected))
+            {
+                mismatches.Add($"{field.Name}: no Rust variant of that name remains");
+
+                continue;
+            }
+
+            matched++;
+            var actual = (int)field.GetValue(null)!;
+            if (actual != expected)
+            {
+                mismatches.Add($"{field.Name}: rust {expected}, .NET {actual}");
+            }
+        }
+
+        Assert.Empty(mismatches);
+        Assert.True(matched > 10, $"Only {matched} error codes were matched by name; the Rust naming drifted.");
+    }
+
+    private static VsrOperation? ResolveOperation(string codeName, int code)
+    {
+        try
+        {
+            return VsrOperations.ForCode(code);
+        }
+        catch (IggyInvalidStatusCodeException)
+        {
+            // The legacy login codes reject rather than resolve; CodeMappingExceptions records that as null.
+            _ = codeName;
+
+            return null;
+        }
+    }
+
+    private static string ActualText(VsrOperation? operation)
+    {
+        return operation?.ToString() ?? "rejected";
+    }
+
+    private static int? CommandCode(string name)
+    {
+        var field = typeof(CommandCodes).GetField(name,
+            BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static);
+
+        return field is null ? null : (int)field.GetValue(null)!;
+    }
+
+    /// <summary>Extracts the body of the <c>matches!</c> invocation inside the named method.</summary>
+    private static string MatchesArmBody(string source, string method)
+    {
+        var start = source.IndexOf($"fn {method}(", StringComparison.Ordinal);
+        Assert.True(start >= 0, $"Operation::{method} is gone from the Rust source.");
+
+        var matches = source.IndexOf("matches!(", start, StringComparison.Ordinal);
+        Assert.True(matches >= 0, $"Operation::{method} no longer classifies with matches!.");
+
+        var depth = 0;
+        for (var i = matches + "matches!".Length; i < source.Length; i++)
+        {
+            depth += source[i] switch
+            {
+                '(' => 1,
+                ')' => -1,
+                _ => 0
+            };
+
+            if (depth == 0)
+            {
+                return source[matches..(i + 1)];
+            }
+        }
+
+        Assert.Fail($"Operation::{method} has an unbalanced matches! invocation.");
+
+        return string.Empty;
+    }
+
+    /// <summary>
+    ///     Pins the eviction reason to error mapping against <c>eviction_reason_to_error</c>, which the Rust
+    ///     module documents as the single grader both its callers share "so the mappings cannot drift apart".
+    ///     The .NET decoder is a third copy outside that guarantee, and the status it produces is what a caller
+    ///     branches on, so an arm that silently regrades here changes public behaviour in one SDK only.
+    /// </summary>
+    [Fact]
+    public void EvictionReasonErrors_MatchTheRustGrader()
+    {
+        IReadOnlyDictionary<string, int> errorCodes = RustErrorCodes();
+        var body = RustItem.Body(ReadRustSource(EvictionGraderPath), "pub fn eviction_reason_to_error(");
+
+        // Arms list one or more reasons separated by `|`; IncompatibleProtocol is a block arm and is asserted
+        // separately below, on both of its branches.
+        var arms = Regex.Matches(body,
+            @"((?:\s*\|?\s*EvictionReason::\w+)+)\s*=>\s*IggyError::(\w+)");
+        Assert.NotEmpty(arms);
+
+        var expected = new Dictionary<string, int>(StringComparer.Ordinal);
+        int? catchAll = null;
+
+        foreach (Match arm in arms)
+        {
+            var code = errorCodes[Normalize(arm.Groups[2].Value)];
+            foreach (Match reason in Regex.Matches(arm.Groups[1].Value, @"EvictionReason::(\w+)"))
+            {
+                expected[reason.Groups[1].Value] = code;
+            }
+        }
+
+        var fallback = Regex.Match(body, @"_\s*=>\s*IggyError::(\w+)");
+        Assert.True(fallback.Success, "eviction_reason_to_error no longer has a catch-all arm.");
+        catchAll = errorCodes[Normalize(fallback.Groups[1].Value)];
+
+        var mismatches = new List<string>();
+
+        foreach (EvictionReason reason in Enum.GetValues<EvictionReason>())
+        {
+            if (reason == EvictionReason.IncompatibleProtocol)
+            {
+                continue;
+            }
+
+            var want = expected.TryGetValue(reason.ToString(), out var mapped) ? mapped : catchAll.Value;
+            var got = StatusOf(reason);
+            if (got != want)
+            {
+                mismatches.Add($"{reason}: rust {want}, .NET {got}");
+            }
+        }
+
+        // A reason this build cannot decode arrives as null and must grade like the Rust catch-all.
+        var unknown = StatusOf(null);
+        if (unknown != catchAll.Value)
+        {
+            mismatches.Add($"<unrecognized>: rust {catchAll.Value}, .NET {unknown}");
+        }
+
+        Assert.Empty(mismatches);
+
+        // IncompatibleProtocol reports the window, except when it is degenerate - a zero minimum or an
+        // inverted range - which falls back to re-authentication.
+        Assert.Equal(errorCodes["INCOMPATIBLEPROTOCOLVERSION"],
+            StatusOf(EvictionReason.IncompatibleProtocol, serverVersion: 12, serverVersionMin: 8));
+        Assert.Equal(errorCodes["UNAUTHENTICATED"],
+            StatusOf(EvictionReason.IncompatibleProtocol, serverVersion: 12, serverVersionMin: 0));
+        Assert.Equal(errorCodes["UNAUTHENTICATED"],
+            StatusOf(EvictionReason.IncompatibleProtocol, serverVersion: 8, serverVersionMin: 12));
+    }
+
+    /// <summary>
+    ///     Pins the result-section widths. They are hardcoded on the .NET side, and the Rust module notes that
+    ///     the decoder and its encode mirror "share the widths below so they cannot drift".
+    /// </summary>
+    [Fact]
+    public void ResultSectionWidths_MatchTheRustConstants()
+    {
+        var source = ReadRustSource(ReplyResultPath);
+
+        Assert.Equal(VsrReplyDecoder.RESULT_COUNT_LENGTH, RustConstant(source, "RESULT_COUNT_LEN"));
+        Assert.Equal(VsrReplyDecoder.RESULT_ENTRY_LENGTH, RustConstant(source, "RESULT_ENTRY_LEN"));
+    }
+
+    /// <summary>
+    ///     Pins the credential bounds the client rejects on before spending a consensus round trip. Bounds that
+    ///     drift wide let the server evict the session instead; bounds that drift narrow reject logins the
+    ///     server would accept.
+    /// </summary>
+    [Fact]
+    public void CredentialBounds_MatchTheRustDefaults()
+    {
+        var source = ReadRustSource(CredentialDefaultsPath);
+
+        Assert.Equal(CredentialBounds.MIN_USERNAME_LENGTH, RustConstant(source, "MIN_USERNAME_LENGTH"));
+        Assert.Equal(CredentialBounds.MAX_USERNAME_LENGTH, RustConstant(source, "MAX_USERNAME_LENGTH"));
+        Assert.Equal(CredentialBounds.MIN_PASSWORD_LENGTH, RustConstant(source, "MIN_PASSWORD_LENGTH"));
+        Assert.Equal(CredentialBounds.MAX_PASSWORD_LENGTH, RustConstant(source, "MAX_PASSWORD_LENGTH"));
+    }
+
+    /// <summary>
+    ///     Pins the register reply body layout by reading the field offsets out of the Rust decoder and feeding
+    ///     .NET a buffer laid out to them. The header offsets are pinned structurally above, but the bodies were
+    ///     only ever checked against hand-written .NET expectations, which move together with the code.
+    /// </summary>
+    [Fact]
+    public void LoginRegisterResponseLayout_MatchesTheRustDecoder()
+    {
+        var body = RustItem.Body(ReadRustSource(LoginRegisterResponsePath), "fn decode(buf: &[u8])");
+
+        Assert.Equal(0, RustReadOffset(body, "read_u32_le", "user_id"));
+        Assert.Equal(4, RustReadOffset(body, "read_u64_le", "session"));
+        Assert.Equal(12, RustReadOffset(body, "read_u32_le", "server_protocol_version"));
+
+        var versionOffset = Regex.Match(body, @"WireName::decode\(&buf\[(\d+)\.\.\]\)");
+        Assert.True(versionOffset.Success, "The register reply no longer decodes its server version by offset.");
+        Assert.Equal(16, Group(versionOffset, 1));
+
+        Span<byte> reply = stackalloc byte[16 + 1 + 3];
+        BinaryPrimitives.WriteUInt32LittleEndian(reply, 7);
+        BinaryPrimitives.WriteUInt64LittleEndian(reply[4..], 42);
+        BinaryPrimitives.WriteUInt32LittleEndian(reply[12..], 99);
+        reply[16] = 3;
+        "1.2"u8.CopyTo(reply[17..]);
+
+        LoginRegisterResponse decoded = LoginRegister.Deserialize(reply);
+
+        Assert.Equal(7u, decoded.UserId);
... 838 lines suppressed ...