Design doc covers architecture, v2 protocol (TypedValue/QualityCode), COM threading model, session lifecycle, subscription semantics, error model, and guardrails. Implementation plans are detailed enough for autonomous Claude Code execution. Verified all dev tooling on windev (Grpc.Tools, protobuf-net.Grpc, Polly v8, xUnit).
26 KiB
Phase 6: Client Extras — Implementation Plan
Date: 2026-03-21
Prerequisites: Phase 5 complete and passing (Client Core — ILmxProxyClient, LmxProxyClient partial classes, ClientMetrics, ISubscription, ApiKeyInfo all functional with unit tests passing)
Working Directory: The lmxproxy repo is on windev at C:\src\lmxproxy
Guardrails
- Client targets .NET 10, AnyCPU — latest C# features permitted.
- Polly v8 API —
ResiliencePipeline,ResiliencePipelineBuilder,RetryStrategyOptions. Do NOT use Polly v7IAsyncPolicy,Policy.Handle<>().WaitAndRetryAsync(...). - Builder default port is 50051 (per design doc section 11 — resolved conflict).
- No new NuGet packages —
Polly 8.5.2,Microsoft.Extensions.DependencyInjection.Abstractions 10.0.0,Microsoft.Extensions.Configuration.Abstractions 10.0.0,Microsoft.Extensions.Configuration.Binder 10.0.0,Microsoft.Extensions.Logging.Abstractions 10.0.0are already in the csproj. - Build command:
dotnet build src/ZB.MOM.WW.LmxProxy.Client - Test command:
dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests
Step 1: LmxProxyClientBuilder
File: src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs
Rewrite the builder for v2. Key changes from v1:
- Default port changes from
5050to50051 - Retry uses Polly v8
ResiliencePipeline(built inSetBuilderConfiguration) WithCorrelationIdHeadersupport
1.1 Builder fields
public class LmxProxyClientBuilder
{
private string? _host;
private int _port = 50051; // CHANGED from 5050
private string? _apiKey;
private ILogger<LmxProxyClient>? _logger;
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
private int _maxRetryAttempts = 3;
private TimeSpan _retryDelay = TimeSpan.FromSeconds(1);
private bool _enableMetrics;
private string? _correlationIdHeader;
private ClientTlsConfiguration? _tlsConfiguration;
1.2 Fluent methods
Each method returns this for chaining. Validation at call site:
| Method | Default | Validation |
|---|---|---|
WithHost(string host) |
Required | !string.IsNullOrWhiteSpace(host) |
WithPort(int port) |
50051 | 1-65535 |
WithApiKey(string? apiKey) |
null | none |
WithLogger(ILogger<LmxProxyClient> logger) |
NullLogger | != null |
WithTimeout(TimeSpan timeout) |
30s | > TimeSpan.Zero && <= TimeSpan.FromMinutes(10) |
WithSslCredentials(string? certificatePath) |
disabled | creates/updates _tlsConfiguration with UseTls=true |
WithTlsConfiguration(ClientTlsConfiguration config) |
null | != null |
WithRetryPolicy(int maxAttempts, TimeSpan retryDelay) |
3, 1s | maxAttempts > 0, retryDelay > TimeSpan.Zero |
WithMetrics() |
disabled | sets _enableMetrics = true |
WithCorrelationIdHeader(string headerName) |
null | !string.IsNullOrEmpty |
1.3 Build()
public LmxProxyClient Build()
{
if (string.IsNullOrWhiteSpace(_host))
throw new InvalidOperationException("Host must be specified. Call WithHost() before Build().");
ValidateTlsConfiguration();
var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
{
DefaultTimeout = _defaultTimeout
};
client.SetBuilderConfiguration(new ClientConfiguration
{
MaxRetryAttempts = _maxRetryAttempts,
RetryDelay = _retryDelay,
EnableMetrics = _enableMetrics,
CorrelationIdHeader = _correlationIdHeader
});
return client;
}
1.4 ValidateTlsConfiguration
If _tlsConfiguration?.UseTls == true:
- If
ServerCaCertificatePathis set and file doesn't exist → throwFileNotFoundException. - If
ClientCertificatePathis set and file doesn't exist → throwFileNotFoundException. - If
ClientKeyPathis set and file doesn't exist → throwFileNotFoundException.
1.5 Polly v8 ResiliencePipeline setup (in LmxProxyClient.SetBuilderConfiguration)
This was defined in Step 4 of Phase 5. Verify it uses:
using Polly;
using Polly.Retry;
using Grpc.Core;
_resiliencePipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = config.MaxRetryAttempts,
Delay = config.RetryDelay,
BackoffType = DelayBackoffType.Exponential,
ShouldHandle = new PredicateBuilder()
.Handle<RpcException>(ex =>
ex.StatusCode == StatusCode.Unavailable ||
ex.StatusCode == StatusCode.DeadlineExceeded ||
ex.StatusCode == StatusCode.ResourceExhausted ||
ex.StatusCode == StatusCode.Aborted),
OnRetry = args =>
{
_logger.LogWarning(
"Retry {Attempt}/{Max} after {Delay}ms — {Error}",
args.AttemptNumber, config.MaxRetryAttempts,
args.RetryDelay.TotalMilliseconds,
args.Outcome.Exception?.Message ?? "unknown");
return ValueTask.CompletedTask;
}
})
.Build();
Backoff sequence: retryDelay * 2^(attempt-1) → 1s, 2s, 4s for defaults.
1.6 Verify build
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
Step 2: ClientConfiguration
File: This is already defined in LmxProxyClientBuilder.cs (at the bottom of the file, as an internal class). Verify it contains:
internal class ClientConfiguration
{
public int MaxRetryAttempts { get; set; }
public TimeSpan RetryDelay { get; set; }
public bool EnableMetrics { get; set; }
public string? CorrelationIdHeader { get; set; }
}
No changes needed if it matches.
Step 3: ILmxProxyClientFactory + LmxProxyClientFactory
File: src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs
3.1 Interface
namespace ZB.MOM.WW.LmxProxy.Client;
public interface ILmxProxyClientFactory
{
LmxProxyClient CreateClient();
LmxProxyClient CreateClient(string configName);
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
}
3.2 Implementation
public class LmxProxyClientFactory : ILmxProxyClientFactory
{
private readonly IConfiguration _configuration;
public LmxProxyClientFactory(IConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
public LmxProxyClient CreateClient() => CreateClient("LmxProxy");
public LmxProxyClient CreateClient(string configName)
{
IConfigurationSection section = _configuration.GetSection(configName);
var options = new LmxProxyClientOptions();
section.Bind(options);
return BuildFromOptions(options);
}
public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
{
var builder = new LmxProxyClientBuilder();
builderAction(builder);
return builder.Build();
}
private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options)
{
var builder = new LmxProxyClientBuilder()
.WithHost(options.Host)
.WithPort(options.Port)
.WithTimeout(options.Timeout)
.WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay);
if (!string.IsNullOrEmpty(options.ApiKey))
builder.WithApiKey(options.ApiKey);
if (options.EnableMetrics)
builder.WithMetrics();
if (!string.IsNullOrEmpty(options.CorrelationIdHeader))
builder.WithCorrelationIdHeader(options.CorrelationIdHeader);
if (options.UseSsl)
{
builder.WithTlsConfiguration(new ClientTlsConfiguration
{
UseTls = true,
ServerCaCertificatePath = options.CertificatePath
});
}
return builder.Build();
}
}
3.3 Verify build
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
Step 4: ServiceCollectionExtensions
File: src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs
4.1 Options classes
Define at the bottom of the file or in a separate LmxProxyClientOptions.cs:
public class LmxProxyClientOptions
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 50051; // CHANGED from 5050
public string? ApiKey { get; set; }
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
public bool UseSsl { get; set; }
public string? CertificatePath { get; set; }
public bool EnableMetrics { get; set; }
public string? CorrelationIdHeader { get; set; }
public RetryOptions Retry { get; set; } = new();
}
public class RetryOptions
{
public int MaxAttempts { get; set; } = 3;
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
}
4.2 Extension methods
public static class ServiceCollectionExtensions
{
/// <summary>Registers a singleton ILmxProxyClient from the "LmxProxy" config section.</summary>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services, IConfiguration configuration)
{
return services.AddLmxProxyClient(configuration, "LmxProxy");
}
/// <summary>Registers a singleton ILmxProxyClient from a named config section.</summary>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services, IConfiguration configuration, string sectionName)
{
services.AddSingleton<ILmxProxyClientFactory>(
sp => new LmxProxyClientFactory(configuration));
services.AddSingleton<ILmxProxyClient>(sp =>
{
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(sectionName);
});
return services;
}
/// <summary>Registers a singleton ILmxProxyClient via builder action.</summary>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services, Action<LmxProxyClientBuilder> configure)
{
services.AddSingleton<ILmxProxyClient>(sp =>
{
var builder = new LmxProxyClientBuilder();
configure(builder);
return builder.Build();
});
return services;
}
/// <summary>Registers a scoped ILmxProxyClient from the "LmxProxy" config section.</summary>
public static IServiceCollection AddScopedLmxProxyClient(
this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<ILmxProxyClientFactory>(
sp => new LmxProxyClientFactory(configuration));
services.AddScoped<ILmxProxyClient>(sp =>
{
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient();
});
return services;
}
/// <summary>Registers a keyed singleton ILmxProxyClient.</summary>
public static IServiceCollection AddNamedLmxProxyClient(
this IServiceCollection services, string name, Action<LmxProxyClientBuilder> configure)
{
services.AddKeyedSingleton<ILmxProxyClient>(name, (sp, key) =>
{
var builder = new LmxProxyClientBuilder();
configure(builder);
return builder.Build();
});
return services;
}
}
4.3 Verify build
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
Step 5: StreamingExtensions
File: src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs
5.1 ReadStreamAsync
public static class StreamingExtensions
{
/// <summary>
/// Reads multiple tags as an async stream in batches.
/// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors.
/// </summary>
public static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
int batchSize = 100,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize));
var batch = new List<string>(batchSize);
int consecutiveErrors = 0;
const int maxConsecutiveErrors = 3;
const int maxRetries = 2;
foreach (string address in addresses)
{
cancellationToken.ThrowIfCancellationRequested();
batch.Add(address);
if (batch.Count >= batchSize)
{
await foreach (var kvp in ReadBatchWithRetry(
client, batch, maxRetries, cancellationToken))
{
consecutiveErrors = 0;
yield return kvp;
}
// If we get here without yielding, it was an error
// (handled inside ReadBatchWithRetry)
batch.Clear();
}
}
// Process remaining
if (batch.Count > 0)
{
await foreach (var kvp in ReadBatchWithRetry(
client, batch, maxRetries, cancellationToken))
{
yield return kvp;
}
}
}
private static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadBatchWithRetry(
ILmxProxyClient client,
List<string> batch,
int maxRetries,
[EnumeratorCancellation] CancellationToken ct)
{
int retries = 0;
while (retries <= maxRetries)
{
IDictionary<string, Vtq>? results = null;
try
{
results = await client.ReadBatchAsync(batch, ct);
}
catch when (retries < maxRetries)
{
retries++;
continue;
}
if (results is not null)
{
foreach (var kvp in results)
yield return kvp;
yield break;
}
retries++;
}
}
5.2 WriteStreamAsync
/// <summary>
/// Writes values from an async enumerable in batches. Returns total count written.
/// </summary>
public static async Task<int> WriteStreamAsync(
this ILmxProxyClient client,
IAsyncEnumerable<KeyValuePair<string, TypedValue>> values,
int batchSize = 100,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(values);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize));
var batch = new Dictionary<string, TypedValue>(batchSize);
int totalWritten = 0;
await foreach (var kvp in values.WithCancellation(cancellationToken))
{
batch[kvp.Key] = kvp.Value;
if (batch.Count >= batchSize)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
batch.Clear();
}
}
if (batch.Count > 0)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
}
return totalWritten;
}
5.3 ProcessInParallelAsync
/// <summary>
/// Processes items in parallel with a configurable max concurrency (default 4).
/// </summary>
public static async Task ProcessInParallelAsync<T>(
this IAsyncEnumerable<T> source,
Func<T, CancellationToken, Task> processor,
int maxConcurrency = 4,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(processor);
if (maxConcurrency <= 0)
throw new ArgumentOutOfRangeException(nameof(maxConcurrency));
using var semaphore = new SemaphoreSlim(maxConcurrency);
var tasks = new List<Task>();
await foreach (T item in source.WithCancellation(cancellationToken))
{
await semaphore.WaitAsync(cancellationToken);
tasks.Add(Task.Run(async () =>
{
try
{
await processor(item, cancellationToken);
}
finally
{
semaphore.Release();
}
}, cancellationToken));
}
await Task.WhenAll(tasks);
}
5.4 SubscribeStreamAsync
/// <summary>
/// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels.
/// </summary>
public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
var channel = Channel.CreateBounded<(string, Vtq)>(
new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
ISubscription? subscription = null;
try
{
subscription = await client.SubscribeAsync(
addresses,
(tag, vtq) =>
{
channel.Writer.TryWrite((tag, vtq));
},
ex =>
{
channel.Writer.TryComplete(ex);
},
cancellationToken);
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
{
yield return item;
}
}
finally
{
subscription?.Dispose();
channel.Writer.TryComplete();
}
}
}
5.5 Verify build
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
Step 6: Properties/AssemblyInfo.cs
File: src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs
Create this file if it doesn't already exist:
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]
This allows the test project to access internal types like ClientMetrics and ClientConfiguration.
6.1 Verify build
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
Step 7: Unit Tests
Add tests to the existing tests/ZB.MOM.WW.LmxProxy.Client.Tests/ project (created in Phase 5).
7.1 Builder Tests
File: tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs
public class LmxProxyClientBuilderTests
{
[Fact]
public void Build_ThrowsWhenHostNotSet()
{
var builder = new LmxProxyClientBuilder();
Assert.Throws<InvalidOperationException>(() => builder.Build());
}
[Fact]
public void Build_DefaultPort_Is50051()
{
var client = new LmxProxyClientBuilder()
.WithHost("localhost")
.Build();
// Verify via reflection or by checking connection attempt URI
Assert.NotNull(client);
}
[Fact]
public void WithPort_ThrowsOnZero()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithPort(0));
}
[Fact]
public void WithPort_ThrowsOn65536()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithPort(65536));
}
[Fact]
public void WithTimeout_ThrowsOnNegative()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1)));
}
[Fact]
public void WithTimeout_ThrowsOver10Minutes()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11)));
}
[Fact]
public void WithRetryPolicy_ThrowsOnZeroAttempts()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1)));
}
[Fact]
public void WithRetryPolicy_ThrowsOnZeroDelay()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero));
}
[Fact]
public void Build_WithAllOptions_Succeeds()
{
var client = new LmxProxyClientBuilder()
.WithHost("10.100.0.48")
.WithPort(50051)
.WithApiKey("test-key")
.WithTimeout(TimeSpan.FromSeconds(15))
.WithRetryPolicy(5, TimeSpan.FromSeconds(2))
.WithMetrics()
.WithCorrelationIdHeader("X-Correlation-ID")
.Build();
Assert.NotNull(client);
}
[Fact]
public void Build_WithTls_ValidatesCertificatePaths()
{
var builder = new LmxProxyClientBuilder()
.WithHost("localhost")
.WithTlsConfiguration(new ClientTlsConfiguration
{
UseTls = true,
ServerCaCertificatePath = "/nonexistent/cert.pem"
});
Assert.Throws<FileNotFoundException>(() => builder.Build());
}
[Fact]
public void WithHost_ThrowsOnNull()
{
Assert.Throws<ArgumentException>(() =>
new LmxProxyClientBuilder().WithHost(null!));
}
[Fact]
public void WithHost_ThrowsOnEmpty()
{
Assert.Throws<ArgumentException>(() =>
new LmxProxyClientBuilder().WithHost(""));
}
}
7.2 Factory Tests
File: tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs
public class LmxProxyClientFactoryTests
{
[Fact]
public void CreateClient_BindsFromConfiguration()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["LmxProxy:Host"] = "10.100.0.48",
["LmxProxy:Port"] = "50052",
["LmxProxy:ApiKey"] = "test-key",
["LmxProxy:Retry:MaxAttempts"] = "5",
["LmxProxy:Retry:Delay"] = "00:00:02",
})
.Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient();
Assert.NotNull(client);
}
[Fact]
public void CreateClient_NamedSection()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MyProxy:Host"] = "10.100.0.48",
["MyProxy:Port"] = "50052",
})
.Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient("MyProxy");
Assert.NotNull(client);
}
[Fact]
public void CreateClient_BuilderAction()
{
var config = new ConfigurationBuilder().Build();
var factory = new LmxProxyClientFactory(config);
var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051));
Assert.NotNull(client);
}
}
7.3 StreamingExtensions Tests
File: tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs
public class StreamingExtensionsTests
{
[Fact]
public async Task ReadStreamAsync_BatchesCorrectly()
// Create mock client, provide 250 addresses with batchSize=100
// Verify ReadBatchAsync called 3 times (100, 100, 50)
[Fact]
public async Task ReadStreamAsync_RetriesOnError()
// Mock first ReadBatchAsync to throw, second to succeed
// Verify results returned from second attempt
[Fact]
public async Task WriteStreamAsync_BatchesAndReturnsCount()
// Provide async enumerable of 250 items, batchSize=100
// Verify WriteBatchAsync called 3 times, total returned = 250
[Fact]
public async Task ProcessInParallelAsync_RespectsMaxConcurrency()
// Track concurrent count with SemaphoreSlim
// maxConcurrency=2, verify never exceeds 2 concurrent calls
[Fact]
public async Task SubscribeStreamAsync_YieldsFromChannel()
// Mock SubscribeAsync to invoke onUpdate callback with test values
// Verify IAsyncEnumerable yields matching items
}
7.4 Run all tests
ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal"
Step 8: Build Verification
Run full solution build and all tests:
ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal"
Completion Criteria
LmxProxyClientBuilderwith default port 50051, Polly v8 wiring, all fluent methods, TLS validationClientConfigurationinternal record with retry, metrics, correlation header fieldsILmxProxyClientFactory+LmxProxyClientFactorywith 3CreateClientoverloadsServiceCollectionExtensionswithAddLmxProxyClient(3 overloads),AddScopedLmxProxyClient,AddNamedLmxProxyClientLmxProxyClientOptions+RetryOptionsconfiguration classesStreamingExtensionswithReadStreamAsync(batched, 2 retries, 3 consecutive error abort),WriteStreamAsync(batched),ProcessInParallelAsync(SemaphoreSlim, max 4),SubscribeStreamAsync(Channel-based IAsyncEnumerable)Properties/AssemblyInfo.cswithInternalsVisibleTofor test project- Builder tests: validation, defaults, Polly pipeline wiring, TLS cert validation
- Factory tests: config binding from IConfiguration, named sections, builder action
- StreamingExtensions tests: batching, error recovery, parallel throttling, subscription streaming
- Solution builds cleanly
- All tests pass