# Phase 1: Protocol & Domain Types — Implementation Plan

## Prerequisites

### Rename existing source to reference
```bash
cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy
mv src src-reference
mkdir -p src/ZB.MOM.WW.LmxProxy.Host
mkdir -p src/ZB.MOM.WW.LmxProxy.Client
mkdir -p tests/ZB.MOM.WW.LmxProxy.Host.Tests
mkdir -p tests/ZB.MOM.WW.LmxProxy.Client.Tests
```

### Update solution file

Overwrite `ZB.MOM.WW.LmxProxy.slnx` with:

```xml
<Solution>
  <Folder Name="/src/">
    <Project Path="src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj" />
    <Project Path="src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj" />
  </Folder>
  <Folder Name="/tests/">
    <Project Path="tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj" />
    <Project Path="tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj" />
  </Folder>
</Solution>
```

## Guardrails

1. **Proto is the source of truth** — any wire format question is resolved by reading `scada.proto`, not the code-first contracts.
2. **No v1 code in the new build** — `src-reference/` is for consultation only. Do not copy-paste and modify; write fresh.
3. **Cross-stack tests in Phase 1** — Host proto serialize → Client code-first deserialize (and vice versa) before any business logic.
4. **status_code is canonical for quality** — `symbolic_name` is always derived, never independently set.
5. **No string serialization heuristics** — v2 uses native TypedValue. No `double.TryParse` or `bool.TryParse` on values.
6. **Each phase must compile and pass tests** before the next phase begins.
7. **No references to old namespaces** — `ZB.MOM.WW.ScadaBridge` or `ZB.MOM.WW.Lmx.Proxy` must not appear anywhere.

---

## Step 1: Create project files

### 1.1 Host csproj

**File**: `src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj`

```xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net48</TargetFramework>
    <OutputType>Exe</OutputType>
    <LangVersion>9.0</LangVersion>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <RootNamespace>ZB.MOM.WW.LmxProxy.Host</RootNamespace>
    <AssemblyName>ZB.MOM.WW.LmxProxy.Host</AssemblyName>
    <PlatformTarget>x86</PlatformTarget>
    <Platforms>x86</Platforms>
    <Prefer32Bit>true</Prefer32Bit>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Grpc.Core" Version="2.46.6" />
    <PackageReference Include="Grpc.Tools" Version="2.71.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Google.Protobuf" Version="3.29.3" />
    <PackageReference Include="Topshelf" Version="4.3.0" />
    <PackageReference Include="Topshelf.Serilog" Version="4.3.0" />
    <PackageReference Include="Serilog" Version="2.10.0" />
    <PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
    <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
    <PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
    <PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0" />
    <PackageReference Include="Serilog.Enrichers.Environment" Version="2.2.0" />
    <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" />
    <PackageReference Include="System.Threading.Channels" Version="4.7.1" />
    <PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32" />
    <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32" />
    <PackageReference Include="Polly" Version="7.2.4" />
    <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.32" />
    <PackageReference Include="System.Memory" Version="4.5.5" />
    <PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.7.1" />
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>

  <ItemGroup>
    <Reference Include="ArchestrA.MXAccess">
      <HintPath>..\..\lib\ArchestrA.MXAccess.dll</HintPath>
      <Private>true</Private>
    </Reference>
  </ItemGroup>

  <ItemGroup>
    <Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both" />
  </ItemGroup>

  <ItemGroup>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="appsettings.*.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

</Project>
```

**Note**: Grpc.Tools bumped to 2.71.0 for latest protoc codegen. Google.Protobuf bumped to 3.29.3 for latest runtime. Polly stays at 7.x because .NET Framework 4.8 does not support Polly v8's modern pipeline API. Newtonsoft.Json added for API key file serialization (System.Text.Json is not well-supported on net48).

### 1.2 Client csproj

**File**: `src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj`

```xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <RootNamespace>ZB.MOM.WW.LmxProxy.Client</RootNamespace>
    <AssemblyName>ZB.MOM.WW.LmxProxy.Client</AssemblyName>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
    <IsPackable>true</IsPackable>
    <Description>gRPC client library for LmxProxy SCADA proxy service</Description>
    <PlatformTarget>AnyCPU</PlatformTarget>
    <Platforms>AnyCPU</Platforms>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Grpc.Core.Api" Version="2.71.0" />
    <PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
    <PackageReference Include="protobuf-net.Grpc" Version="1.2.5" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
    <PackageReference Include="Polly" Version="8.5.2" />
  </ItemGroup>

</Project>
```

### 1.3 Host.Tests csproj

**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj`

```xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net48</TargetFramework>
    <LangVersion>9.0</LangVersion>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <RootNamespace>ZB.MOM.WW.LmxProxy.Host.Tests</RootNamespace>
    <PlatformTarget>x86</PlatformTarget>
    <Platforms>x86</Platforms>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="FluentAssertions" Version="6.12.2" />
    <PackageReference Include="Google.Protobuf" Version="3.29.3" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\ZB.MOM.WW.LmxProxy.Host.csproj" />
  </ItemGroup>

</Project>
```

### 1.4 Client.Tests csproj

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj`

```xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <RootNamespace>ZB.MOM.WW.LmxProxy.Client.Tests</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="FluentAssertions" Version="7.2.0" />
    <PackageReference Include="Google.Protobuf" Version="3.29.3" />
    <PackageReference Include="Grpc.Tools" Version="2.71.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />
  </ItemGroup>

  <!-- Proto file for cross-stack serialization tests (Host proto → Client code-first) -->
  <ItemGroup>
    <Protobuf Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\Grpc\Protos\scada.proto" GrpcServices="None" Link="Protos\scada.proto" />
  </ItemGroup>

</Project>
```

**Important note on cross-stack tests**: The Client.Tests project includes the Host's proto file with `GrpcServices="None"` (message types only, no service stubs). This lets us serialize using Google.Protobuf generated types and deserialize using protobuf-net code-first types, verifying wire compatibility without a running gRPC server.

---

## Step 2: Write v2 proto file

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto`

Create the directory first: `mkdir -p src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos`

Write the complete v2 proto file exactly as follows:

```protobuf
syntax = "proto3";
package scada;

// ============================================================
// Service Definition
// ============================================================

service ScadaService {
  rpc Connect(ConnectRequest) returns (ConnectResponse);
  rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
  rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
  rpc Read(ReadRequest) returns (ReadResponse);
  rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
  rpc Write(WriteRequest) returns (WriteResponse);
  rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
  rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
  rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
  rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
}

// ============================================================
// Typed Value System
// ============================================================

message TypedValue {
  oneof value {
    bool       bool_value     = 1;
    int32      int32_value    = 2;
    int64      int64_value    = 3;
    float      float_value    = 4;
    double     double_value   = 5;
    string     string_value   = 6;
    bytes      bytes_value    = 7;
    int64      datetime_value = 8;   // UTC DateTime.Ticks (100ns intervals since 0001-01-01)
    ArrayValue array_value    = 9;
  }
}

message ArrayValue {
  oneof values {
    BoolArray   bool_values   = 1;
    Int32Array  int32_values  = 2;
    Int64Array  int64_values  = 3;
    FloatArray  float_values  = 4;
    DoubleArray double_values = 5;
    StringArray string_values = 6;
  }
}

message BoolArray   { repeated bool   values = 1; }
message Int32Array  { repeated int32  values = 1; }
message Int64Array  { repeated int64  values = 1; }
message FloatArray  { repeated float  values = 1; }
message DoubleArray { repeated double values = 1; }
message StringArray { repeated string values = 1; }

// ============================================================
// OPC UA-Style Quality Codes
// ============================================================

message QualityCode {
  uint32 status_code   = 1;
  string symbolic_name = 2;
}

// ============================================================
// Connection Lifecycle
// ============================================================

message ConnectRequest {
  string client_id = 1;
  string api_key   = 2;
}

message ConnectResponse {
  bool   success    = 1;
  string message    = 2;
  string session_id = 3;
}

message DisconnectRequest {
  string session_id = 1;
}

message DisconnectResponse {
  bool   success = 1;
  string message = 2;
}

message GetConnectionStateRequest {
  string session_id = 1;
}

message GetConnectionStateResponse {
  bool   is_connected              = 1;
  string client_id                 = 2;
  int64  connected_since_utc_ticks = 3;
}

message CheckApiKeyRequest {
  string api_key = 1;
}

message CheckApiKeyResponse {
  bool   is_valid = 1;
  string message  = 2;
}

// ============================================================
// Value-Timestamp-Quality
// ============================================================

message VtqMessage {
  string      tag                 = 1;
  TypedValue  value               = 2;
  int64       timestamp_utc_ticks = 3;
  QualityCode quality             = 4;
}

// ============================================================
// Read Operations
// ============================================================

message ReadRequest {
  string session_id = 1;
  string tag        = 2;
}

message ReadResponse {
  bool       success = 1;
  string     message = 2;
  VtqMessage vtq     = 3;
}

message ReadBatchRequest {
  string          session_id = 1;
  repeated string tags       = 2;
}

message ReadBatchResponse {
  bool                 success = 1;
  string               message = 2;
  repeated VtqMessage  vtqs    = 3;
}

// ============================================================
// Write Operations
// ============================================================

message WriteRequest {
  string     session_id = 1;
  string     tag        = 2;
  TypedValue value      = 3;
}

message WriteResponse {
  bool   success = 1;
  string message = 2;
}

message WriteItem {
  string     tag   = 1;
  TypedValue value = 2;
}

message WriteResult {
  string tag     = 1;
  bool   success = 2;
  string message = 3;
}

message WriteBatchRequest {
  string             session_id = 1;
  repeated WriteItem items      = 2;
}

message WriteBatchResponse {
  bool                 success = 1;
  string               message = 2;
  repeated WriteResult results = 3;
}

// ============================================================
// WriteBatchAndWait
// ============================================================

message WriteBatchAndWaitRequest {
  string             session_id       = 1;
  repeated WriteItem items            = 2;
  string             flag_tag         = 3;
  TypedValue         flag_value       = 4;
  int32              timeout_ms       = 5;
  int32              poll_interval_ms = 6;
}

message WriteBatchAndWaitResponse {
  bool                 success       = 1;
  string               message       = 2;
  repeated WriteResult write_results = 3;
  bool                 flag_reached  = 4;
  int32                elapsed_ms    = 5;
}

// ============================================================
// Subscription
// ============================================================

message SubscribeRequest {
  string          session_id  = 1;
  repeated string tags        = 2;
  int32           sampling_ms = 3;
}
```

---

## Step 3: Host domain types

All Host domain types go in namespace `ZB.MOM.WW.LmxProxy.Host.Domain`.

### 3.1 Quality enum

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs`

```csharp
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     OPC quality codes mapped to domain-level values.
    ///     The byte value matches the low-order byte of the OPC DA quality code,
    ///     enabling direct round-trip between the domain enum and the wire OPC DA byte.
    /// </summary>
    public enum Quality : byte
    {
        // ─────────────── Bad family (0-31) ───────────────
        /// <summary>0x00 - Bad [Non-Specific]</summary>
        Bad = 0,

        /// <summary>0x01 - Unknown quality value</summary>
        Unknown = 1,

        /// <summary>0x04 - Bad [Configuration Error]</summary>
        Bad_ConfigError = 4,

        /// <summary>0x08 - Bad [Not Connected]</summary>
        Bad_NotConnected = 8,

        /// <summary>0x0C - Bad [Device Failure]</summary>
        Bad_DeviceFailure = 12,

        /// <summary>0x10 - Bad [Sensor Failure]</summary>
        Bad_SensorFailure = 16,

        /// <summary>0x14 - Bad [Last Known Value]</summary>
        Bad_LastKnownValue = 20,

        /// <summary>0x18 - Bad [Communication Failure]</summary>
        Bad_CommFailure = 24,

        /// <summary>0x1C - Bad [Out of Service]</summary>
        Bad_OutOfService = 28,

        /// <summary>0x20 - Bad [Waiting for Initial Data]</summary>
        Bad_WaitingForInitialData = 32,

        // ──────────── Uncertain family (64-95) ───────────
        /// <summary>0x40 - Uncertain [Non-Specific]</summary>
        Uncertain = 64,

        /// <summary>0x41 - Uncertain [Non-Specific] (Low Limited)</summary>
        Uncertain_LowLimited = 65,

        /// <summary>0x42 - Uncertain [Non-Specific] (High Limited)</summary>
        Uncertain_HighLimited = 66,

        /// <summary>0x43 - Uncertain [Non-Specific] (Constant)</summary>
        Uncertain_Constant = 67,

        /// <summary>0x44 - Uncertain [Last Usable]</summary>
        Uncertain_LastUsable = 68,

        /// <summary>0x45 - Uncertain [Last Usable] (Low Limited)</summary>
        Uncertain_LastUsable_LL = 69,

        /// <summary>0x46 - Uncertain [Last Usable] (High Limited)</summary>
        Uncertain_LastUsable_HL = 70,

        /// <summary>0x47 - Uncertain [Last Usable] (Constant)</summary>
        Uncertain_LastUsable_Cnst = 71,

        /// <summary>0x50 - Uncertain [Sensor Not Accurate]</summary>
        Uncertain_SensorNotAcc = 80,

        /// <summary>0x51 - Uncertain [Sensor Not Accurate] (Low Limited)</summary>
        Uncertain_SensorNotAcc_LL = 81,

        /// <summary>0x52 - Uncertain [Sensor Not Accurate] (High Limited)</summary>
        Uncertain_SensorNotAcc_HL = 82,

        /// <summary>0x53 - Uncertain [Sensor Not Accurate] (Constant)</summary>
        Uncertain_SensorNotAcc_C = 83,

        /// <summary>0x54 - Uncertain [EU Exceeded]</summary>
        Uncertain_EuExceeded = 84,

        /// <summary>0x55 - Uncertain [EU Exceeded] (Low Limited)</summary>
        Uncertain_EuExceeded_LL = 85,

        /// <summary>0x56 - Uncertain [EU Exceeded] (High Limited)</summary>
        Uncertain_EuExceeded_HL = 86,

        /// <summary>0x57 - Uncertain [EU Exceeded] (Constant)</summary>
        Uncertain_EuExceeded_C = 87,

        /// <summary>0x58 - Uncertain [Sub-Normal]</summary>
        Uncertain_SubNormal = 88,

        /// <summary>0x59 - Uncertain [Sub-Normal] (Low Limited)</summary>
        Uncertain_SubNormal_LL = 89,

        /// <summary>0x5A - Uncertain [Sub-Normal] (High Limited)</summary>
        Uncertain_SubNormal_HL = 90,

        /// <summary>0x5B - Uncertain [Sub-Normal] (Constant)</summary>
        Uncertain_SubNormal_C = 91,

        // ─────────────── Good family (192-219) ────────────
        /// <summary>0xC0 - Good [Non-Specific]</summary>
        Good = 192,

        /// <summary>0xC1 - Good [Non-Specific] (Low Limited)</summary>
        Good_LowLimited = 193,

        /// <summary>0xC2 - Good [Non-Specific] (High Limited)</summary>
        Good_HighLimited = 194,

        /// <summary>0xC3 - Good [Non-Specific] (Constant)</summary>
        Good_Constant = 195,

        /// <summary>0xD8 - Good [Local Override]</summary>
        Good_LocalOverride = 216,

        /// <summary>0xD9 - Good [Local Override] (Low Limited)</summary>
        Good_LocalOverride_LL = 217,

        /// <summary>0xDA - Good [Local Override] (High Limited)</summary>
        Good_LocalOverride_HL = 218,

        /// <summary>0xDB - Good [Local Override] (Constant)</summary>
        Good_LocalOverride_C = 219
    }
}
```

### 3.2 QualityExtensions

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityExtensions.cs`

```csharp
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     Extension methods for the <see cref="Quality"/> enum.
    /// </summary>
    public static class QualityExtensions
    {
        /// <summary>Returns true if quality is in the Good family (byte >= 192).</summary>
        public static bool IsGood(this Quality q) => (byte)q >= 192;

        /// <summary>Returns true if quality is in the Uncertain family (byte 64-127).</summary>
        public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 128;

        /// <summary>Returns true if quality is in the Bad family (byte < 64).</summary>
        public static bool IsBad(this Quality q) => (byte)q < 64;
    }
}
```

### 3.3 Vtq

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs`

```csharp
using System;

namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     Value, Timestamp, and Quality structure for SCADA data.
    /// </summary>
    public readonly struct Vtq : IEquatable<Vtq>
    {
        /// <summary>Gets the value. Null represents an unset/missing value.</summary>
        public object? Value { get; }

        /// <summary>Gets the UTC timestamp when the value was read.</summary>
        public DateTime Timestamp { get; }

        /// <summary>Gets the quality of the value.</summary>
        public Quality Quality { get; }

        public Vtq(object? value, DateTime timestamp, Quality quality)
        {
            Value = value;
            Timestamp = timestamp;
            Quality = quality;
        }

        public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
        public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
        public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
        public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
        public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);

        public bool Equals(Vtq other) =>
            Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality;

        public override bool Equals(object obj) => obj is Vtq other && Equals(other);

        public override int GetHashCode()
        {
            unchecked
            {
                int hashCode = Value != null ? Value.GetHashCode() : 0;
                hashCode = (hashCode * 397) ^ Timestamp.GetHashCode();
                hashCode = (hashCode * 397) ^ (int)Quality;
                return hashCode;
            }
        }

        public override string ToString() =>
            $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";

        public static bool operator ==(Vtq left, Vtq right) => left.Equals(right);
        public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right);
    }
}
```

### 3.4 ConnectionState

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionState.cs`

```csharp
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     Represents the state of a SCADA client connection.
    /// </summary>
    public enum ConnectionState
    {
        Disconnected,
        Connecting,
        Connected,
        Disconnecting,
        Error,
        Reconnecting
    }
}
```

### 3.5 ConnectionStateChangedEventArgs

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/ConnectionStateChangedEventArgs.cs`

```csharp
using System;

namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     Event arguments for SCADA client connection state changes.
    /// </summary>
    public class ConnectionStateChangedEventArgs : EventArgs
    {
        public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState,
            string? message = null)
        {
            PreviousState = previousState;
            CurrentState = currentState;
            Timestamp = DateTime.UtcNow;
            Message = message;
        }

        public ConnectionState PreviousState { get; }
        public ConnectionState CurrentState { get; }
        public DateTime Timestamp { get; }
        public string? Message { get; }
    }
}
```

### 3.6 IScadaClient interface

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/IScadaClient.cs`

This is the v2 interface. Note the `WriteBatchAndWaitAsync` signature now matches the v2 protocol semantics (write items, poll flagTag for flagValue).

```csharp
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     Interface for SCADA system clients (MxAccess wrapper).
    /// </summary>
    public interface IScadaClient : IAsyncDisposable
    {
        /// <summary>Gets whether the client is connected to MxAccess.</summary>
        bool IsConnected { get; }

        /// <summary>Gets the current connection state.</summary>
        ConnectionState ConnectionState { get; }

        /// <summary>Occurs when the connection state changes.</summary>
        event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;

        /// <summary>Connects to MxAccess.</summary>
        Task ConnectAsync(CancellationToken ct = default);

        /// <summary>Disconnects from MxAccess.</summary>
        Task DisconnectAsync(CancellationToken ct = default);

        /// <summary>Reads a single tag value.</summary>
        /// <returns>VTQ with typed value.</returns>
        Task<Vtq> ReadAsync(string address, CancellationToken ct = default);

        /// <summary>Reads multiple tag values with semaphore-controlled concurrency.</summary>
        /// <returns>Dictionary of address to VTQ.</returns>
        Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);

        /// <summary>Writes a single tag value. Value is a native .NET type (not string).</summary>
        Task WriteAsync(string address, object value, CancellationToken ct = default);

        /// <summary>Writes multiple tag values with semaphore-controlled concurrency.</summary>
        Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default);

        /// <summary>
        ///     Writes a batch of values, then polls flagTag until it equals flagValue or timeout expires.
        ///     Returns (writeSuccess, flagReached, elapsedMs).
        /// </summary>
        /// <param name="values">Tag-value pairs to write.</param>
        /// <param name="flagTag">Tag to poll after writes.</param>
        /// <param name="flagValue">Expected value (type-aware comparison).</param>
        /// <param name="timeoutMs">Max wait time in milliseconds.</param>
        /// <param name="pollIntervalMs">Poll interval in milliseconds.</param>
        /// <param name="ct">Cancellation token.</param>
        Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
            IReadOnlyDictionary<string, object> values,
            string flagTag,
            object flagValue,
            int timeoutMs,
            int pollIntervalMs,
            CancellationToken ct = default);

        /// <summary>Subscribes to value changes for specified addresses.</summary>
        /// <returns>Subscription handle for unsubscribing.</returns>
        Task<IAsyncDisposable> SubscribeAsync(
            IEnumerable<string> addresses,
            Action<string, Vtq> callback,
            CancellationToken ct = default);
    }
}
```

### 3.7 Placeholder Program.cs (so Host compiles)

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Program.cs`

```csharp
namespace ZB.MOM.WW.LmxProxy.Host
{
    internal static class Program
    {
        static void Main(string[] args)
        {
            // Placeholder - Phase 3 will implement full Topshelf startup.
        }
    }
}
```

### 3.8 Placeholder appsettings.json (so Host compiles)

**File**: `src/ZB.MOM.WW.LmxProxy.Host/appsettings.json`

```json
{
}
```

---

## Step 4: Client domain types

All Client domain types go in namespace `ZB.MOM.WW.LmxProxy.Client.Domain`.

### 4.1 Quality enum

**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/Quality.cs`

Identical enum values as Host (same byte values), but in the Client namespace. File-scoped namespace for .NET 10:

```csharp
namespace ZB.MOM.WW.LmxProxy.Client.Domain;

/// <summary>
/// OPC-style quality codes for SCADA data values.
/// Byte value matches OPC DA quality low byte for direct round-trip.
/// </summary>
public enum Quality : byte
{
    // ─────────────── Bad family (0-31) ───────────────
    Bad = 0,
    Bad_ConfigError = 4,
    Bad_NotConnected = 8,
    Bad_DeviceFailure = 12,
    Bad_SensorFailure = 16,
    Bad_LastKnownValue = 20,
    Bad_CommFailure = 24,
    Bad_OutOfService = 28,
    Bad_WaitingForInitialData = 32,

    // ──────────── Uncertain family (64-95) ───────────
    Uncertain = 64,
    Uncertain_LowLimited = 65,
    Uncertain_HighLimited = 66,
    Uncertain_Constant = 67,
    Uncertain_LastUsable = 68,
    Uncertain_LastUsable_LL = 69,
    Uncertain_LastUsable_HL = 70,
    Uncertain_LastUsable_Cnst = 71,
    Uncertain_SensorNotAcc = 80,
    Uncertain_SensorNotAcc_LL = 81,
    Uncertain_SensorNotAcc_HL = 82,
    Uncertain_SensorNotAcc_C = 83,
    Uncertain_EuExceeded = 84,
    Uncertain_EuExceeded_LL = 85,
    Uncertain_EuExceeded_HL = 86,
    Uncertain_EuExceeded_C = 87,
    Uncertain_SubNormal = 88,
    Uncertain_SubNormal_LL = 89,
    Uncertain_SubNormal_HL = 90,
    Uncertain_SubNormal_C = 91,

    // ─────────────── Good family (192-219) ────────────
    Good = 192,
    Good_LowLimited = 193,
    Good_HighLimited = 194,
    Good_Constant = 195,
    Good_LocalOverride = 216,
    Good_LocalOverride_LL = 217,
    Good_LocalOverride_HL = 218,
    Good_LocalOverride_C = 219
}
```

### 4.2 QualityExtensions

**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/QualityExtensions.cs`

```csharp
namespace ZB.MOM.WW.LmxProxy.Client.Domain;

/// <summary>Extension methods for <see cref="Quality"/>.</summary>
public static class QualityExtensions
{
    /// <summary>Returns true if quality is in the Good family (byte >= 192).</summary>
    public static bool IsGood(this Quality q) => (byte)q >= 192;

    /// <summary>Returns true if quality is in the Uncertain family (byte 64-127).</summary>
    public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128;

    /// <summary>Returns true if quality is in the Bad family (byte < 64).</summary>
    public static bool IsBad(this Quality q) => (byte)q < 64;
}
```

### 4.3 Vtq

**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/Vtq.cs`

```csharp
namespace ZB.MOM.WW.LmxProxy.Client.Domain;

/// <summary>Value, Timestamp, and Quality for SCADA data.</summary>
public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality)
{
    public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
    public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
    public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);

    public override string ToString() =>
        $"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
}
```

### 4.4 ConnectionState

**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/ConnectionState.cs`

```csharp
namespace ZB.MOM.WW.LmxProxy.Client.Domain;

/// <summary>Represents the state of a connection to the LmxProxy service.</summary>
public enum ConnectionState
{
    Disconnected,
    Connecting,
    Connected,
    Disconnecting,
    Error,
    Reconnecting
}
```

### 4.5 ScadaContracts.cs (v2 code-first contracts)

**File**: `src/ZB.MOM.WW.LmxProxy.Client/Domain/ScadaContracts.cs`

This is the complete v2 code-first contract file. Every `[DataMember(Order = N)]` matches the proto field number exactly. `TypedValue` and `QualityCode` are new additions. The `IScadaService` method names follow protobuf-net.Grpc conventions (operation name matching).

```csharp
using System.Collections.Generic;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading;
using System.Threading.Tasks;

namespace ZB.MOM.WW.LmxProxy.Client.Domain;

// ────────────────────────────────────────────────────────────────
// Service contract
// ────────────────────────────────────────────────────────────────

[ServiceContract(Name = "scada.ScadaService")]
public interface IScadaService
{
    ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
    ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
    ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
    ValueTask<ReadResponse> ReadAsync(ReadRequest request);
    ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
    ValueTask<WriteResponse> WriteAsync(WriteRequest request);
    ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
    ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
    IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
    ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
}

// ────────────────────────────────────────────────────────────────
// Typed Value System (v2)
// ────────────────────────────────────────────────────────────────

/// <summary>
/// Carries a value in its native type via a protobuf oneof.
/// Exactly one property will be non-default. All-default = null value.
/// protobuf-net uses the first non-default field in field-number order for oneof.
/// </summary>
[DataContract]
public class TypedValue
{
    [DataMember(Order = 1)]
    public bool BoolValue { get; set; }

    [DataMember(Order = 2)]
    public int Int32Value { get; set; }

    [DataMember(Order = 3)]
    public long Int64Value { get; set; }

    [DataMember(Order = 4)]
    public float FloatValue { get; set; }

    [DataMember(Order = 5)]
    public double DoubleValue { get; set; }

    [DataMember(Order = 6)]
    public string? StringValue { get; set; }

    [DataMember(Order = 7)]
    public byte[]? BytesValue { get; set; }

    [DataMember(Order = 8)]
    public long DatetimeValue { get; set; }

    [DataMember(Order = 9)]
    public ArrayValue? ArrayValue { get; set; }

    /// <summary>
    /// Indicates which oneof case is set. Determined by checking non-default values.
    /// This is NOT a wire field -- it's a convenience helper.
    /// </summary>
    public TypedValueCase GetValueCase()
    {
        // Check in reverse priority order to handle protobuf oneof semantics.
        // For the oneof, only one should be set at a time.
        if (ArrayValue != null) return TypedValueCase.ArrayValue;
        if (DatetimeValue != 0) return TypedValueCase.DatetimeValue;
        if (BytesValue != null) return TypedValueCase.BytesValue;
        if (StringValue != null) return TypedValueCase.StringValue;
        if (DoubleValue != 0d) return TypedValueCase.DoubleValue;
        if (FloatValue != 0f) return TypedValueCase.FloatValue;
        if (Int64Value != 0) return TypedValueCase.Int64Value;
        if (Int32Value != 0) return TypedValueCase.Int32Value;
        if (BoolValue) return TypedValueCase.BoolValue;
        return TypedValueCase.None;
    }
}

/// <summary>Identifies which field in TypedValue is set.</summary>
public enum TypedValueCase
{
    None = 0,
    BoolValue = 1,
    Int32Value = 2,
    Int64Value = 3,
    FloatValue = 4,
    DoubleValue = 5,
    StringValue = 6,
    BytesValue = 7,
    DatetimeValue = 8,
    ArrayValue = 9
}

/// <summary>Container for typed arrays. Exactly one field will be set.</summary>
[DataContract]
public class ArrayValue
{
    [DataMember(Order = 1)]
    public BoolArray? BoolValues { get; set; }

    [DataMember(Order = 2)]
    public Int32Array? Int32Values { get; set; }

    [DataMember(Order = 3)]
    public Int64Array? Int64Values { get; set; }

    [DataMember(Order = 4)]
    public FloatArray? FloatValues { get; set; }

    [DataMember(Order = 5)]
    public DoubleArray? DoubleValues { get; set; }

    [DataMember(Order = 6)]
    public StringArray? StringValues { get; set; }
}

[DataContract]
public class BoolArray
{
    [DataMember(Order = 1)]
    public List<bool> Values { get; set; } = [];
}

[DataContract]
public class Int32Array
{
    [DataMember(Order = 1)]
    public List<int> Values { get; set; } = [];
}

[DataContract]
public class Int64Array
{
    [DataMember(Order = 1)]
    public List<long> Values { get; set; } = [];
}

[DataContract]
public class FloatArray
{
    [DataMember(Order = 1)]
    public List<float> Values { get; set; } = [];
}

[DataContract]
public class DoubleArray
{
    [DataMember(Order = 1)]
    public List<double> Values { get; set; } = [];
}

[DataContract]
public class StringArray
{
    [DataMember(Order = 1)]
    public List<string> Values { get; set; } = [];
}

// ────────────────────────────────────────────────────────────────
// Quality Code (v2)
// ────────────────────────────────────────────────────────────────

/// <summary>
/// OPC UA-style quality code with numeric status code and symbolic name.
/// </summary>
[DataContract]
public class QualityCode
{
    [DataMember(Order = 1)]
    public uint StatusCode { get; set; }

    [DataMember(Order = 2)]
    public string SymbolicName { get; set; } = string.Empty;

    /// <summary>Returns true if quality category is Good (high bits 0x00).</summary>
    public bool IsGood => (StatusCode & 0xC0000000) == 0x00000000;

    /// <summary>Returns true if quality category is Uncertain (high bits 0x40).</summary>
    public bool IsUncertain => (StatusCode & 0xC0000000) == 0x40000000;

    /// <summary>Returns true if quality category is Bad (high bits 0x80).</summary>
    public bool IsBad => (StatusCode & 0xC0000000) == 0x80000000;
}

// ────────────────────────────────────────────────────────────────
// VTQ message (v2)
// ────────────────────────────────────────────────────────────────

[DataContract]
public class VtqMessage
{
    [DataMember(Order = 1)]
    public string Tag { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public TypedValue? Value { get; set; }

    [DataMember(Order = 3)]
    public long TimestampUtcTicks { get; set; }

    [DataMember(Order = 4)]
    public QualityCode? Quality { get; set; }
}

// ────────────────────────────────────────────────────────────────
// Connect
// ────────────────────────────────────────────────────────────────

[DataContract]
public class ConnectRequest
{
    [DataMember(Order = 1)]
    public string ClientId { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public string ApiKey { get; set; } = string.Empty;
}

[DataContract]
public class ConnectResponse
{
    [DataMember(Order = 1)]
    public bool Success { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;

    [DataMember(Order = 3)]
    public string SessionId { get; set; } = string.Empty;
}

// ────────────────────────────────────────────────────────────────
// Disconnect
// ────────────────────────────────────────────────────────────────

[DataContract]
public class DisconnectRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;
}

[DataContract]
public class DisconnectResponse
{
    [DataMember(Order = 1)]
    public bool Success { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;
}

// ────────────────────────────────────────────────────────────────
// GetConnectionState
// ────────────────────────────────────────────────────────────────

[DataContract]
public class GetConnectionStateRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;
}

[DataContract]
public class GetConnectionStateResponse
{
    [DataMember(Order = 1)]
    public bool IsConnected { get; set; }

    [DataMember(Order = 2)]
    public string ClientId { get; set; } = string.Empty;

    [DataMember(Order = 3)]
    public long ConnectedSinceUtcTicks { get; set; }
}

// ────────────────────────────────────────────────────────────────
// Read
// ────────────────────────────────────────────────────────────────

[DataContract]
public class ReadRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public string Tag { get; set; } = string.Empty;
}

[DataContract]
public class ReadResponse
{
    [DataMember(Order = 1)]
    public bool Success { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;

    [DataMember(Order = 3)]
    public VtqMessage? Vtq { get; set; }
}

[DataContract]
public class ReadBatchRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public List<string> Tags { get; set; } = [];
}

[DataContract]
public class ReadBatchResponse
{
    [DataMember(Order = 1)]
    public bool Success { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;

    [DataMember(Order = 3)]
    public List<VtqMessage> Vtqs { get; set; } = [];
}

// ────────────────────────────────────────────────────────────────
// Write
// ────────────────────────────────────────────────────────────────

[DataContract]
public class WriteRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public string Tag { get; set; } = string.Empty;

    [DataMember(Order = 3)]
    public TypedValue? Value { get; set; }
}

[DataContract]
public class WriteResponse
{
    [DataMember(Order = 1)]
    public bool Success { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;
}

[DataContract]
public class WriteItem
{
    [DataMember(Order = 1)]
    public string Tag { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public TypedValue? Value { get; set; }
}

[DataContract]
public class WriteResult
{
    [DataMember(Order = 1)]
    public string Tag { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public bool Success { get; set; }

    [DataMember(Order = 3)]
    public string Message { get; set; } = string.Empty;
}

[DataContract]
public class WriteBatchRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public List<WriteItem> Items { get; set; } = [];
}

[DataContract]
public class WriteBatchResponse
{
    [DataMember(Order = 1)]
    public bool Success { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;

    [DataMember(Order = 3)]
    public List<WriteResult> Results { get; set; } = [];
}

// ────────────────────────────────────────────────────────────────
// WriteBatchAndWait
// ────────────────────────────────────────────────────────────────

[DataContract]
public class WriteBatchAndWaitRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public List<WriteItem> Items { get; set; } = [];

    [DataMember(Order = 3)]
    public string FlagTag { get; set; } = string.Empty;

    [DataMember(Order = 4)]
    public TypedValue? FlagValue { get; set; }

    [DataMember(Order = 5)]
    public int TimeoutMs { get; set; }

    [DataMember(Order = 6)]
    public int PollIntervalMs { get; set; }
}

[DataContract]
public class WriteBatchAndWaitResponse
{
    [DataMember(Order = 1)]
    public bool Success { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;

    [DataMember(Order = 3)]
    public List<WriteResult> WriteResults { get; set; } = [];

    [DataMember(Order = 4)]
    public bool FlagReached { get; set; }

    [DataMember(Order = 5)]
    public int ElapsedMs { get; set; }
}

// ────────────────────────────────────────────────────────────────
// Subscribe
// ────────────────────────────────────────────────────────────────

[DataContract]
public class SubscribeRequest
{
    [DataMember(Order = 1)]
    public string SessionId { get; set; } = string.Empty;

    [DataMember(Order = 2)]
    public List<string> Tags { get; set; } = [];

    [DataMember(Order = 3)]
    public int SamplingMs { get; set; }
}

// ────────────────────────────────────────────────────────────────
// CheckApiKey
// ────────────────────────────────────────────────────────────────

[DataContract]
public class CheckApiKeyRequest
{
    [DataMember(Order = 1)]
    public string ApiKey { get; set; } = string.Empty;
}

[DataContract]
public class CheckApiKeyResponse
{
    [DataMember(Order = 1)]
    public bool IsValid { get; set; }

    [DataMember(Order = 2)]
    public string Message { get; set; } = string.Empty;
}
```

---

## Step 5: COM variant coercion helpers (Host-side)

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/TypedValueConverter.cs`

This static class converts between COM variant objects (boxed .NET types from MxAccess) and the proto-generated `Scada.TypedValue` message. The proto codegen will produce classes in namespace `Scada` (from `package scada;`).

```csharp
using System;
using Google.Protobuf;
using Serilog;

namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     Converts between COM variant objects (boxed .NET types from MxAccess)
    ///     and proto-generated <see cref="Scada.TypedValue"/> messages.
    /// </summary>
    public static class TypedValueConverter
    {
        private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TypedValueConverter));

        /// <summary>
        ///     Converts a COM variant object to a proto TypedValue.
        ///     Returns null (unset TypedValue) for null, DBNull, or VT_EMPTY/VT_NULL.
        /// </summary>
        public static Scada.TypedValue? ToTypedValue(object? value)
        {
            if (value == null || value is DBNull)
                return null;

            switch (value)
            {
                case bool b:
                    return new Scada.TypedValue { BoolValue = b };

                case short s:       // VT_I2 → widened to int32
                    return new Scada.TypedValue { Int32Value = s };

                case int i:         // VT_I4
                    return new Scada.TypedValue { Int32Value = i };

                case long l:        // VT_I8
                    return new Scada.TypedValue { Int64Value = l };

                case ushort us:     // VT_UI2 → widened to int32
                    return new Scada.TypedValue { Int32Value = us };

                case uint ui:       // VT_UI4 → widened to int64 to avoid sign issues
                    return new Scada.TypedValue { Int64Value = ui };

                case ulong ul:      // VT_UI8 → int64, truncation risk
                    if (ul > (ulong)long.MaxValue)
                        Log.Warning("ulong value {Value} exceeds long.MaxValue, truncation will occur", ul);
                    return new Scada.TypedValue { Int64Value = (long)ul };

                case float f:       // VT_R4
                    return new Scada.TypedValue { FloatValue = f };

                case double d:      // VT_R8
                    return new Scada.TypedValue { DoubleValue = d };

                case string str:    // VT_BSTR
                    return new Scada.TypedValue { StringValue = str };

                case DateTime dt:   // VT_DATE → UTC Ticks
                    return new Scada.TypedValue { DatetimeValue = dt.ToUniversalTime().Ticks };

                case decimal dec:   // VT_DECIMAL → double (precision loss)
                    Log.Warning("Decimal value {Value} converted to double, precision loss may occur", dec);
                    return new Scada.TypedValue { DoubleValue = (double)dec };

                case byte[] bytes:  // VT_ARRAY of bytes
                    return new Scada.TypedValue { BytesValue = ByteString.CopyFrom(bytes) };

                case bool[] boolArr:
                {
                    var arr = new Scada.BoolArray();
                    arr.Values.AddRange(boolArr);
                    return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { BoolValues = arr } };
                }

                case int[] intArr:
                {
                    var arr = new Scada.Int32Array();
                    arr.Values.AddRange(intArr);
                    return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int32Values = arr } };
                }

                case long[] longArr:
                {
                    var arr = new Scada.Int64Array();
                    arr.Values.AddRange(longArr);
                    return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int64Values = arr } };
                }

                case float[] floatArr:
                {
                    var arr = new Scada.FloatArray();
                    arr.Values.AddRange(floatArr);
                    return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { FloatValues = arr } };
                }

                case double[] doubleArr:
                {
                    var arr = new Scada.DoubleArray();
                    arr.Values.AddRange(doubleArr);
                    return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DoubleValues = arr } };
                }

                case string[] strArr:
                {
                    var arr = new Scada.StringArray();
                    arr.Values.AddRange(strArr);
                    return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { StringValues = arr } };
                }

                default:
                    // VT_UNKNOWN or any unrecognized type — ToString() fallback
                    Log.Warning("Unrecognized COM variant type {Type}, using ToString() fallback", value.GetType().Name);
                    return new Scada.TypedValue { StringValue = value.ToString() };
            }
        }

        /// <summary>
        ///     Converts a proto TypedValue back to a boxed .NET object.
        ///     Returns null for unset oneof (null TypedValue or ValueCase.None).
        /// </summary>
        public static object? FromTypedValue(Scada.TypedValue? typedValue)
        {
            if (typedValue == null)
                return null;

            switch (typedValue.ValueCase)
            {
                case Scada.TypedValue.ValueOneofCase.BoolValue:
                    return typedValue.BoolValue;

                case Scada.TypedValue.ValueOneofCase.Int32Value:
                    return typedValue.Int32Value;

                case Scada.TypedValue.ValueOneofCase.Int64Value:
                    return typedValue.Int64Value;

                case Scada.TypedValue.ValueOneofCase.FloatValue:
                    return typedValue.FloatValue;

                case Scada.TypedValue.ValueOneofCase.DoubleValue:
                    return typedValue.DoubleValue;

                case Scada.TypedValue.ValueOneofCase.StringValue:
                    return typedValue.StringValue;

                case Scada.TypedValue.ValueOneofCase.BytesValue:
                    return typedValue.BytesValue.ToByteArray();

                case Scada.TypedValue.ValueOneofCase.DatetimeValue:
                    return new DateTime(typedValue.DatetimeValue, DateTimeKind.Utc);

                case Scada.TypedValue.ValueOneofCase.ArrayValue:
                    return FromArrayValue(typedValue.ArrayValue);

                case Scada.TypedValue.ValueOneofCase.None:
                default:
                    return null;
            }
        }

        private static object? FromArrayValue(Scada.ArrayValue? arrayValue)
        {
            if (arrayValue == null)
                return null;

            switch (arrayValue.ValuesCase)
            {
                case Scada.ArrayValue.ValuesOneofCase.BoolValues:
                    return arrayValue.BoolValues?.Values?.Count > 0
                        ? ToArray(arrayValue.BoolValues.Values)
                        : Array.Empty<bool>();

                case Scada.ArrayValue.ValuesOneofCase.Int32Values:
                    return arrayValue.Int32Values?.Values?.Count > 0
                        ? ToArray(arrayValue.Int32Values.Values)
                        : Array.Empty<int>();

                case Scada.ArrayValue.ValuesOneofCase.Int64Values:
                    return arrayValue.Int64Values?.Values?.Count > 0
                        ? ToArray(arrayValue.Int64Values.Values)
                        : Array.Empty<long>();

                case Scada.ArrayValue.ValuesOneofCase.FloatValues:
                    return arrayValue.FloatValues?.Values?.Count > 0
                        ? ToArray(arrayValue.FloatValues.Values)
                        : Array.Empty<float>();

                case Scada.ArrayValue.ValuesOneofCase.DoubleValues:
                    return arrayValue.DoubleValues?.Values?.Count > 0
                        ? ToArray(arrayValue.DoubleValues.Values)
                        : Array.Empty<double>();

                case Scada.ArrayValue.ValuesOneofCase.StringValues:
                    return arrayValue.StringValues?.Values?.Count > 0
                        ? ToArray(arrayValue.StringValues.Values)
                        : Array.Empty<string>();

                default:
                    return null;
            }
        }

        private static T[] ToArray<T>(Google.Protobuf.Collections.RepeatedField<T> repeatedField)
        {
            var result = new T[repeatedField.Count];
            for (int i = 0; i < repeatedField.Count; i++)
                result[i] = repeatedField[i];
            return result;
        }
    }
}
```

---

## Step 6: QualityCode helpers (Host-side)

**File**: `src/ZB.MOM.WW.LmxProxy.Host/Domain/QualityCodeMapper.cs`

Maps between the domain `Quality` enum and proto `QualityCode` messages.

```csharp
using System.Collections.Generic;

namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
    /// <summary>
    ///     Maps between the domain <see cref="Quality"/> enum and proto QualityCode messages.
    ///     status_code (uint32) is canonical. symbolic_name is derived from a lookup table.
    /// </summary>
    public static class QualityCodeMapper
    {
        /// <summary>OPC UA status code → symbolic name lookup.</summary>
        private static readonly Dictionary<uint, string> StatusCodeToName = new Dictionary<uint, string>
        {
            // Good
            { 0x00000000, "Good" },
            { 0x00D80000, "GoodLocalOverride" },

            // Uncertain
            { 0x40900000, "UncertainLastUsableValue" },
            { 0x42390000, "UncertainSensorNotAccurate" },
            { 0x40540000, "UncertainEngineeringUnitsExceeded" },
            { 0x40580000, "UncertainSubNormal" },

            // Bad
            { 0x80000000, "Bad" },
            { 0x80040000, "BadConfigurationError" },
            { 0x808A0000, "BadNotConnected" },
            { 0x806B0000, "BadDeviceFailure" },
            { 0x806D0000, "BadSensorFailure" },
            { 0x80050000, "BadCommunicationFailure" },
            { 0x808F0000, "BadOutOfService" },
            { 0x80320000, "BadWaitingForInitialData" },
        };

        /// <summary>Domain Quality enum → OPC UA status code.</summary>
        private static readonly Dictionary<Quality, uint> QualityToStatusCode = new Dictionary<Quality, uint>
        {
            // Good family
            { Quality.Good, 0x00000000 },
            { Quality.Good_LowLimited, 0x00000000 },
            { Quality.Good_HighLimited, 0x00000000 },
            { Quality.Good_Constant, 0x00000000 },
            { Quality.Good_LocalOverride, 0x00D80000 },
            { Quality.Good_LocalOverride_LL, 0x00D80000 },
            { Quality.Good_LocalOverride_HL, 0x00D80000 },
            { Quality.Good_LocalOverride_C, 0x00D80000 },

            // Uncertain family
            { Quality.Uncertain, 0x40900000 },
            { Quality.Uncertain_LowLimited, 0x40900000 },
            { Quality.Uncertain_HighLimited, 0x40900000 },
            { Quality.Uncertain_Constant, 0x40900000 },
            { Quality.Uncertain_LastUsable, 0x40900000 },
            { Quality.Uncertain_LastUsable_LL, 0x40900000 },
            { Quality.Uncertain_LastUsable_HL, 0x40900000 },
            { Quality.Uncertain_LastUsable_Cnst, 0x40900000 },
            { Quality.Uncertain_SensorNotAcc, 0x42390000 },
            { Quality.Uncertain_SensorNotAcc_LL, 0x42390000 },
            { Quality.Uncertain_SensorNotAcc_HL, 0x42390000 },
            { Quality.Uncertain_SensorNotAcc_C, 0x42390000 },
            { Quality.Uncertain_EuExceeded, 0x40540000 },
            { Quality.Uncertain_EuExceeded_LL, 0x40540000 },
            { Quality.Uncertain_EuExceeded_HL, 0x40540000 },
            { Quality.Uncertain_EuExceeded_C, 0x40540000 },
            { Quality.Uncertain_SubNormal, 0x40580000 },
            { Quality.Uncertain_SubNormal_LL, 0x40580000 },
            { Quality.Uncertain_SubNormal_HL, 0x40580000 },
            { Quality.Uncertain_SubNormal_C, 0x40580000 },

            // Bad family
            { Quality.Bad, 0x80000000 },
            { Quality.Unknown, 0x80000000 },
            { Quality.Bad_ConfigError, 0x80040000 },
            { Quality.Bad_NotConnected, 0x808A0000 },
            { Quality.Bad_DeviceFailure, 0x806B0000 },
            { Quality.Bad_SensorFailure, 0x806D0000 },
            { Quality.Bad_LastKnownValue, 0x80050000 },
            { Quality.Bad_CommFailure, 0x80050000 },
            { Quality.Bad_OutOfService, 0x808F0000 },
            { Quality.Bad_WaitingForInitialData, 0x80320000 },
        };

        /// <summary>
        ///     Converts a domain Quality enum to a proto QualityCode message.
        /// </summary>
        public static Scada.QualityCode ToQualityCode(Quality quality)
        {
            var statusCode = QualityToStatusCode.TryGetValue(quality, out var code) ? code : 0x80000000u;
            var symbolicName = StatusCodeToName.TryGetValue(statusCode, out var name) ? name : "Bad";

            return new Scada.QualityCode
            {
                StatusCode = statusCode,
                SymbolicName = symbolicName
            };
        }

        /// <summary>
        ///     Converts an OPC UA status code (uint32) to a domain Quality enum.
        ///     Falls back to the nearest category if the specific code is not mapped.
        /// </summary>
        public static Quality FromStatusCode(uint statusCode)
        {
            // Exact match first — iterate QualityToStatusCode to find matching Quality
            foreach (var kvp in QualityToStatusCode)
            {
                if (kvp.Value == statusCode)
                    return kvp.Key;
            }

            // Category fallback
            uint category = statusCode & 0xC0000000;
            if (category == 0x00000000) return Quality.Good;
            if (category == 0x40000000) return Quality.Uncertain;
            return Quality.Bad;
        }

        /// <summary>
        ///     Gets the symbolic name for a status code.
        /// </summary>
        public static string GetSymbolicName(uint statusCode)
        {
            if (StatusCodeToName.TryGetValue(statusCode, out var name))
                return name;

            uint category = statusCode & 0xC0000000;
            if (category == 0x00000000) return "Good";
            if (category == 0x40000000) return "Uncertain";
            return "Bad";
        }

        /// <summary>
        ///     Creates a QualityCode for a specific well-known status.
        /// </summary>
        public static Scada.QualityCode Good() => new Scada.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" };
        public static Scada.QualityCode Bad() => new Scada.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" };
        public static Scada.QualityCode BadConfigurationError() => new Scada.QualityCode { StatusCode = 0x80040000, SymbolicName = "BadConfigurationError" };
        public static Scada.QualityCode BadCommunicationFailure() => new Scada.QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" };
        public static Scada.QualityCode BadNotConnected() => new Scada.QualityCode { StatusCode = 0x808A0000, SymbolicName = "BadNotConnected" };
        public static Scada.QualityCode BadDeviceFailure() => new Scada.QualityCode { StatusCode = 0x806B0000, SymbolicName = "BadDeviceFailure" };
        public static Scada.QualityCode BadSensorFailure() => new Scada.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" };
        public static Scada.QualityCode BadOutOfService() => new Scada.QualityCode { StatusCode = 0x808F0000, SymbolicName = "BadOutOfService" };
        public static Scada.QualityCode BadWaitingForInitialData() => new Scada.QualityCode { StatusCode = 0x80320000, SymbolicName = "BadWaitingForInitialData" };
        public static Scada.QualityCode GoodLocalOverride() => new Scada.QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" };
        public static Scada.QualityCode UncertainLastUsableValue() => new Scada.QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" };
    }
}
```

---

## Step 7: Unit tests

### 7.1 Host.Tests: TypedValueConverter tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/TypedValueConverterTests.cs`

```csharp
using System;
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Host.Domain;

namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
{
    public class TypedValueConverterTests
    {
        [Fact]
        public void Null_RoundTrips()
        {
            var tv = TypedValueConverter.ToTypedValue(null);
            tv.Should().BeNull();
            TypedValueConverter.FromTypedValue(null).Should().BeNull();
        }

        [Fact]
        public void DBNull_MapsToNull()
        {
            var tv = TypedValueConverter.ToTypedValue(DBNull.Value);
            tv.Should().BeNull();
        }

        [Fact]
        public void Bool_RoundTrips()
        {
            var tv = TypedValueConverter.ToTypedValue(true);
            tv.Should().NotBeNull();
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BoolValue);
            tv.BoolValue.Should().BeTrue();
            TypedValueConverter.FromTypedValue(tv).Should().Be(true);

            var tvFalse = TypedValueConverter.ToTypedValue(false);
            tvFalse!.BoolValue.Should().BeFalse();
            TypedValueConverter.FromTypedValue(tvFalse).Should().Be(false);
        }

        [Fact]
        public void Short_WidensToInt32()
        {
            var tv = TypedValueConverter.ToTypedValue((short)42);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
            tv.Int32Value.Should().Be(42);
            TypedValueConverter.FromTypedValue(tv).Should().Be(42);
        }

        [Fact]
        public void Int_RoundTrips()
        {
            var tv = TypedValueConverter.ToTypedValue(int.MaxValue);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
            tv.Int32Value.Should().Be(int.MaxValue);
            TypedValueConverter.FromTypedValue(tv).Should().Be(int.MaxValue);
        }

        [Fact]
        public void Long_RoundTrips()
        {
            var tv = TypedValueConverter.ToTypedValue(long.MaxValue);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
            tv.Int64Value.Should().Be(long.MaxValue);
            TypedValueConverter.FromTypedValue(tv).Should().Be(long.MaxValue);
        }

        [Fact]
        public void UShort_WidensToInt32()
        {
            var tv = TypedValueConverter.ToTypedValue((ushort)65535);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int32Value);
            tv.Int32Value.Should().Be(65535);
        }

        [Fact]
        public void UInt_WidensToInt64()
        {
            var tv = TypedValueConverter.ToTypedValue(uint.MaxValue);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
            tv.Int64Value.Should().Be(uint.MaxValue);
        }

        [Fact]
        public void ULong_MapsToInt64()
        {
            var tv = TypedValueConverter.ToTypedValue((ulong)12345678);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.Int64Value);
            tv.Int64Value.Should().Be(12345678);
        }

        [Fact]
        public void Float_RoundTrips()
        {
            var tv = TypedValueConverter.ToTypedValue(3.14159f);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.FloatValue);
            tv.FloatValue.Should().Be(3.14159f);
            TypedValueConverter.FromTypedValue(tv).Should().Be(3.14159f);
        }

        [Fact]
        public void Double_RoundTrips()
        {
            var tv = TypedValueConverter.ToTypedValue(2.718281828459045);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue);
            tv.DoubleValue.Should().Be(2.718281828459045);
            TypedValueConverter.FromTypedValue(tv).Should().Be(2.718281828459045);
        }

        [Fact]
        public void String_RoundTrips()
        {
            var tv = TypedValueConverter.ToTypedValue("Hello World");
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue);
            tv.StringValue.Should().Be("Hello World");
            TypedValueConverter.FromTypedValue(tv).Should().Be("Hello World");
        }

        [Fact]
        public void DateTime_RoundTrips_AsUtcTicks()
        {
            var dt = new DateTime(2026, 3, 21, 12, 0, 0, DateTimeKind.Utc);
            var tv = TypedValueConverter.ToTypedValue(dt);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DatetimeValue);
            tv.DatetimeValue.Should().Be(dt.Ticks);
            var result = (DateTime)TypedValueConverter.FromTypedValue(tv)!;
            result.Kind.Should().Be(DateTimeKind.Utc);
            result.Ticks.Should().Be(dt.Ticks);
        }

        [Fact]
        public void ByteArray_RoundTrips()
        {
            var bytes = new byte[] { 0x00, 0xFF, 0x42 };
            var tv = TypedValueConverter.ToTypedValue(bytes);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.BytesValue);
            var result = (byte[])TypedValueConverter.FromTypedValue(tv)!;
            result.Should().BeEquivalentTo(bytes);
        }

        [Fact]
        public void Decimal_MapsToDouble()
        {
            var tv = TypedValueConverter.ToTypedValue(123.456m);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.DoubleValue);
            tv.DoubleValue.Should().BeApproximately(123.456, 0.001);
        }

        [Fact]
        public void FloatArray_RoundTrips()
        {
            var arr = new float[] { 1.0f, 2.0f, 3.0f };
            var tv = TypedValueConverter.ToTypedValue(arr);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
            var result = (float[])TypedValueConverter.FromTypedValue(tv)!;
            result.Should().BeEquivalentTo(arr);
        }

        [Fact]
        public void IntArray_RoundTrips()
        {
            var arr = new int[] { 10, 20, 30 };
            var tv = TypedValueConverter.ToTypedValue(arr);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
            var result = (int[])TypedValueConverter.FromTypedValue(tv)!;
            result.Should().BeEquivalentTo(arr);
        }

        [Fact]
        public void StringArray_RoundTrips()
        {
            var arr = new string[] { "a", "b", "c" };
            var tv = TypedValueConverter.ToTypedValue(arr);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
            var result = (string[])TypedValueConverter.FromTypedValue(tv)!;
            result.Should().BeEquivalentTo(arr);
        }

        [Fact]
        public void DoubleArray_RoundTrips()
        {
            var arr = new double[] { 1.1, 2.2, 3.3 };
            var tv = TypedValueConverter.ToTypedValue(arr);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.ArrayValue);
            var result = (double[])TypedValueConverter.FromTypedValue(tv)!;
            result.Should().BeEquivalentTo(arr);
        }

        [Fact]
        public void UnrecognizedType_FallsBackToString()
        {
            var guid = Guid.NewGuid();
            var tv = TypedValueConverter.ToTypedValue(guid);
            tv!.ValueCase.Should().Be(Scada.TypedValue.ValueOneofCase.StringValue);
            tv.StringValue.Should().Be(guid.ToString());
        }
    }
}
```

### 7.2 Host.Tests: QualityCodeMapper tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityCodeMapperTests.cs`

```csharp
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Host.Domain;

namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
{
    public class QualityCodeMapperTests
    {
        [Theory]
        [InlineData(Quality.Good, 0x00000000u, "Good")]
        [InlineData(Quality.Good_LocalOverride, 0x00D80000u, "GoodLocalOverride")]
        [InlineData(Quality.Bad, 0x80000000u, "Bad")]
        [InlineData(Quality.Bad_ConfigError, 0x80040000u, "BadConfigurationError")]
        [InlineData(Quality.Bad_NotConnected, 0x808A0000u, "BadNotConnected")]
        [InlineData(Quality.Bad_DeviceFailure, 0x806B0000u, "BadDeviceFailure")]
        [InlineData(Quality.Bad_SensorFailure, 0x806D0000u, "BadSensorFailure")]
        [InlineData(Quality.Bad_CommFailure, 0x80050000u, "BadCommunicationFailure")]
        [InlineData(Quality.Bad_OutOfService, 0x808F0000u, "BadOutOfService")]
        [InlineData(Quality.Bad_WaitingForInitialData, 0x80320000u, "BadWaitingForInitialData")]
        [InlineData(Quality.Uncertain_LastUsable, 0x40900000u, "UncertainLastUsableValue")]
        [InlineData(Quality.Uncertain_SensorNotAcc, 0x42390000u, "UncertainSensorNotAccurate")]
        [InlineData(Quality.Uncertain_EuExceeded, 0x40540000u, "UncertainEngineeringUnitsExceeded")]
        [InlineData(Quality.Uncertain_SubNormal, 0x40580000u, "UncertainSubNormal")]
        public void ToQualityCode_MapsCorrectly(Quality quality, uint expectedStatusCode, string expectedName)
        {
            var qc = QualityCodeMapper.ToQualityCode(quality);
            qc.StatusCode.Should().Be(expectedStatusCode);
            qc.SymbolicName.Should().Be(expectedName);
        }

        [Theory]
        [InlineData(0x00000000u, Quality.Good)]
        [InlineData(0x80000000u, Quality.Bad)]
        [InlineData(0x80040000u, Quality.Bad_ConfigError)]
        [InlineData(0x806D0000u, Quality.Bad_SensorFailure)]
        [InlineData(0x40900000u, Quality.Uncertain_LastUsable)]
        public void FromStatusCode_MapsCorrectly(uint statusCode, Quality expectedQuality)
        {
            QualityCodeMapper.FromStatusCode(statusCode).Should().Be(expectedQuality);
        }

        [Fact]
        public void FromStatusCode_UnknownGoodCode_FallsBackToGood()
        {
            QualityCodeMapper.FromStatusCode(0x00FF0000).Should().Be(Quality.Good);
        }

        [Fact]
        public void FromStatusCode_UnknownBadCode_FallsBackToBad()
        {
            QualityCodeMapper.FromStatusCode(0x80FF0000).Should().Be(Quality.Bad);
        }

        [Fact]
        public void FromStatusCode_UnknownUncertainCode_FallsBackToUncertain()
        {
            QualityCodeMapper.FromStatusCode(0x40FF0000).Should().Be(Quality.Uncertain);
        }

        [Theory]
        [InlineData(0x00000000u, "Good")]
        [InlineData(0x80000000u, "Bad")]
        [InlineData(0x806D0000u, "BadSensorFailure")]
        [InlineData(0x40900000u, "UncertainLastUsableValue")]
        [InlineData(0x80FF0000u, "Bad")]  // unknown bad code falls back
        public void GetSymbolicName_ReturnsCorrectName(uint statusCode, string expectedName)
        {
            QualityCodeMapper.GetSymbolicName(statusCode).Should().Be(expectedName);
        }

        [Fact]
        public void FactoryMethods_ReturnCorrectCodes()
        {
            QualityCodeMapper.Good().StatusCode.Should().Be(0x00000000u);
            QualityCodeMapper.Bad().StatusCode.Should().Be(0x80000000u);
            QualityCodeMapper.BadConfigurationError().StatusCode.Should().Be(0x80040000u);
            QualityCodeMapper.BadCommunicationFailure().StatusCode.Should().Be(0x80050000u);
            QualityCodeMapper.BadNotConnected().StatusCode.Should().Be(0x808A0000u);
            QualityCodeMapper.BadDeviceFailure().StatusCode.Should().Be(0x806B0000u);
            QualityCodeMapper.BadSensorFailure().StatusCode.Should().Be(0x806D0000u);
            QualityCodeMapper.BadOutOfService().StatusCode.Should().Be(0x808F0000u);
            QualityCodeMapper.BadWaitingForInitialData().StatusCode.Should().Be(0x80320000u);
            QualityCodeMapper.GoodLocalOverride().StatusCode.Should().Be(0x00D80000u);
            QualityCodeMapper.UncertainLastUsableValue().StatusCode.Should().Be(0x40900000u);
        }
    }
}
```

### 7.3 Host.Tests: Quality extensions tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Domain/QualityExtensionsTests.cs`

```csharp
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Host.Domain;

namespace ZB.MOM.WW.LmxProxy.Host.Tests.Domain
{
    public class QualityExtensionsTests
    {
        [Theory]
        [InlineData(Quality.Good, true)]
        [InlineData(Quality.Good_LocalOverride, true)]
        [InlineData(Quality.Uncertain, false)]
        [InlineData(Quality.Bad, false)]
        public void IsGood(Quality q, bool expected)
        {
            q.IsGood().Should().Be(expected);
        }

        [Theory]
        [InlineData(Quality.Uncertain, true)]
        [InlineData(Quality.Uncertain_LastUsable, true)]
        [InlineData(Quality.Good, false)]
        [InlineData(Quality.Bad, false)]
        public void IsUncertain(Quality q, bool expected)
        {
            q.IsUncertain().Should().Be(expected);
        }

        [Theory]
        [InlineData(Quality.Bad, true)]
        [InlineData(Quality.Bad_CommFailure, true)]
        [InlineData(Quality.Good, false)]
        [InlineData(Quality.Uncertain, false)]
        public void IsBad(Quality q, bool expected)
        {
            q.IsBad().Should().Be(expected);
        }
    }
}
```

### 7.4 Client.Tests: ScadaContracts tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/ScadaContractsTests.cs`

```csharp
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;

namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;

public class ScadaContractsTests
{
    [Fact]
    public void TypedValue_GetValueCase_Bool()
    {
        var tv = new TypedValue { BoolValue = true };
        tv.GetValueCase().Should().Be(TypedValueCase.BoolValue);
    }

    [Fact]
    public void TypedValue_GetValueCase_Int32()
    {
        var tv = new TypedValue { Int32Value = 42 };
        tv.GetValueCase().Should().Be(TypedValueCase.Int32Value);
    }

    [Fact]
    public void TypedValue_GetValueCase_Double()
    {
        var tv = new TypedValue { DoubleValue = 3.14 };
        tv.GetValueCase().Should().Be(TypedValueCase.DoubleValue);
    }

    [Fact]
    public void TypedValue_GetValueCase_String()
    {
        var tv = new TypedValue { StringValue = "hello" };
        tv.GetValueCase().Should().Be(TypedValueCase.StringValue);
    }

    [Fact]
    public void TypedValue_GetValueCase_None_WhenDefault()
    {
        var tv = new TypedValue();
        tv.GetValueCase().Should().Be(TypedValueCase.None);
    }

    [Fact]
    public void TypedValue_GetValueCase_Datetime()
    {
        var tv = new TypedValue { DatetimeValue = DateTime.UtcNow.Ticks };
        tv.GetValueCase().Should().Be(TypedValueCase.DatetimeValue);
    }

    [Fact]
    public void TypedValue_GetValueCase_BytesValue()
    {
        var tv = new TypedValue { BytesValue = new byte[] { 1, 2, 3 } };
        tv.GetValueCase().Should().Be(TypedValueCase.BytesValue);
    }

    [Fact]
    public void TypedValue_GetValueCase_ArrayValue()
    {
        var tv = new TypedValue
        {
            ArrayValue = new ArrayValue
            {
                FloatValues = new FloatArray { Values = { 1.0f, 2.0f } }
            }
        };
        tv.GetValueCase().Should().Be(TypedValueCase.ArrayValue);
    }

    [Fact]
    public void QualityCode_IsGood()
    {
        var qc = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" };
        qc.IsGood.Should().BeTrue();
        qc.IsBad.Should().BeFalse();
        qc.IsUncertain.Should().BeFalse();
    }

    [Fact]
    public void QualityCode_IsBad()
    {
        var qc = new QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" };
        qc.IsGood.Should().BeFalse();
        qc.IsBad.Should().BeTrue();
        qc.IsUncertain.Should().BeFalse();
    }

    [Fact]
    public void QualityCode_IsUncertain()
    {
        var qc = new QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" };
        qc.IsGood.Should().BeFalse();
        qc.IsBad.Should().BeFalse();
        qc.IsUncertain.Should().BeTrue();
    }

    [Fact]
    public void VtqMessage_DefaultProperties()
    {
        var vtq = new VtqMessage();
        vtq.Tag.Should().BeEmpty();
        vtq.Value.Should().BeNull();
        vtq.TimestampUtcTicks.Should().Be(0);
        vtq.Quality.Should().BeNull();
    }

    [Fact]
    public void WriteBatchAndWaitRequest_FlagValue_IsTypedValue()
    {
        var req = new WriteBatchAndWaitRequest
        {
            SessionId = "abc",
            FlagTag = "Motor.Done",
            FlagValue = new TypedValue { BoolValue = true },
            TimeoutMs = 5000,
            PollIntervalMs = 100
        };
        req.FlagValue.Should().NotBeNull();
        req.FlagValue!.GetValueCase().Should().Be(TypedValueCase.BoolValue);
    }

    [Fact]
    public void WriteItem_Value_IsTypedValue()
    {
        var item = new WriteItem
        {
            Tag = "Motor.Speed",
            Value = new TypedValue { DoubleValue = 42.5 }
        };
        item.Value.Should().NotBeNull();
        item.Value!.GetValueCase().Should().Be(TypedValueCase.DoubleValue);
    }
}
```

### 7.5 Client.Tests: Quality extensions tests

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/QualityExtensionsTests.cs`

```csharp
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;

namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;

public class QualityExtensionsTests
{
    [Theory]
    [InlineData(Quality.Good, true)]
    [InlineData(Quality.Good_LocalOverride, true)]
    [InlineData(Quality.Uncertain, false)]
    [InlineData(Quality.Bad, false)]
    public void IsGood(Quality q, bool expected) => q.IsGood().Should().Be(expected);

    [Theory]
    [InlineData(Quality.Uncertain, true)]
    [InlineData(Quality.Uncertain_LastUsable, true)]
    [InlineData(Quality.Good, false)]
    [InlineData(Quality.Bad, false)]
    public void IsUncertain(Quality q, bool expected) => q.IsUncertain().Should().Be(expected);

    [Theory]
    [InlineData(Quality.Bad, true)]
    [InlineData(Quality.Bad_CommFailure, true)]
    [InlineData(Quality.Good, false)]
    [InlineData(Quality.Uncertain, false)]
    public void IsBad(Quality q, bool expected) => q.IsBad().Should().Be(expected);
}
```

### 7.6 Client.Tests: Vtq factory methods

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/Domain/VtqTests.cs`

```csharp
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;

namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;

public class VtqTests
{
    [Fact]
    public void Good_FactoryMethod()
    {
        var vtq = Vtq.Good(42.0);
        vtq.Value.Should().Be(42.0);
        vtq.Quality.Should().Be(Quality.Good);
        vtq.Timestamp.Kind.Should().Be(DateTimeKind.Utc);
    }

    [Fact]
    public void Bad_FactoryMethod()
    {
        var vtq = Vtq.Bad();
        vtq.Value.Should().BeNull();
        vtq.Quality.Should().Be(Quality.Bad);
    }

    [Fact]
    public void Uncertain_FactoryMethod()
    {
        var vtq = Vtq.Uncertain("stale");
        vtq.Value.Should().Be("stale");
        vtq.Quality.Should().Be(Quality.Uncertain);
    }
}
```

---

## Step 8: Cross-stack contract tests

These tests verify that bytes serialized by Host proto-generated code can be deserialized by Client code-first code (and vice versa). The Client.Tests project includes the Host's `scada.proto` with `GrpcServices="None"` to get the Google.Protobuf generated message classes.

**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/CrossStack/CrossStackSerializationTests.cs`

```csharp
using System.IO;
using FluentAssertions;
using Google.Protobuf;
using ProtoBuf;
using Xunit;
using ProtoGenerated = Scada;
using CodeFirst = ZB.MOM.WW.LmxProxy.Client.Domain;

namespace ZB.MOM.WW.LmxProxy.Client.Tests.CrossStack;

/// <summary>
/// Verifies wire compatibility between Host proto-generated types and Client code-first types.
/// Serializes with one stack, deserializes with the other.
/// </summary>
public class CrossStackSerializationTests
{
    // ── Proto-generated → Code-first ──────────────────────────

    [Fact]
    public void VtqMessage_ProtoToCodeFirst_BoolValue()
    {
        // Arrange: proto-generated VtqMessage with bool TypedValue
        var protoMsg = new ProtoGenerated.VtqMessage
        {
            Tag = "Motor.Running",
            Value = new ProtoGenerated.TypedValue { BoolValue = true },
            TimestampUtcTicks = 638789000000000000L,
            Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
        };

        // Act: serialize with proto, deserialize with protobuf-net
        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));

        // Assert
        codeFirst.Should().NotBeNull();
        codeFirst.Tag.Should().Be("Motor.Running");
        codeFirst.Value.Should().NotBeNull();
        codeFirst.Value!.BoolValue.Should().BeTrue();
        codeFirst.TimestampUtcTicks.Should().Be(638789000000000000L);
        codeFirst.Quality.Should().NotBeNull();
        codeFirst.Quality!.StatusCode.Should().Be(0x00000000u);
        codeFirst.Quality.SymbolicName.Should().Be("Good");
    }

    [Fact]
    public void VtqMessage_ProtoToCodeFirst_DoubleValue()
    {
        var protoMsg = new ProtoGenerated.VtqMessage
        {
            Tag = "Motor.Speed",
            Value = new ProtoGenerated.TypedValue { DoubleValue = 42.5 },
            TimestampUtcTicks = 638789000000000000L,
            Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
        };

        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));

        codeFirst.Value.Should().NotBeNull();
        codeFirst.Value!.DoubleValue.Should().Be(42.5);
    }

    [Fact]
    public void VtqMessage_ProtoToCodeFirst_StringValue()
    {
        var protoMsg = new ProtoGenerated.VtqMessage
        {
            Tag = "Motor.Name",
            Value = new ProtoGenerated.TypedValue { StringValue = "Pump A" },
            TimestampUtcTicks = 638789000000000000L,
            Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
        };

        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));

        codeFirst.Value.Should().NotBeNull();
        codeFirst.Value!.StringValue.Should().Be("Pump A");
    }

    [Fact]
    public void VtqMessage_ProtoToCodeFirst_Int32Value()
    {
        var protoMsg = new ProtoGenerated.VtqMessage
        {
            Tag = "Motor.Count",
            Value = new ProtoGenerated.TypedValue { Int32Value = 2147483647 },
            TimestampUtcTicks = 638789000000000000L,
            Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
        };

        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));

        codeFirst.Value!.Int32Value.Should().Be(int.MaxValue);
    }

    [Fact]
    public void VtqMessage_ProtoToCodeFirst_BadQuality()
    {
        var protoMsg = new ProtoGenerated.VtqMessage
        {
            Tag = "Motor.Fault",
            TimestampUtcTicks = 638789000000000000L,
            Quality = new ProtoGenerated.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }
        };

        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));

        codeFirst.Quality!.StatusCode.Should().Be(0x806D0000u);
        codeFirst.Quality.SymbolicName.Should().Be("BadSensorFailure");
        codeFirst.Quality.IsBad.Should().BeTrue();
    }

    [Fact]
    public void VtqMessage_ProtoToCodeFirst_NullValue()
    {
        // No Value field set — represents null
        var protoMsg = new ProtoGenerated.VtqMessage
        {
            Tag = "Motor.Optional",
            TimestampUtcTicks = 638789000000000000L,
            Quality = new ProtoGenerated.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }
        };

        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));

        // When no oneof is set, the Value object may be null or all-default
        // Either way, GetValueCase() should return None
        if (codeFirst.Value != null)
            codeFirst.Value.GetValueCase().Should().Be(CodeFirst.TypedValueCase.None);
    }

    [Fact]
    public void VtqMessage_ProtoToCodeFirst_FloatArrayValue()
    {
        var floatArr = new ProtoGenerated.FloatArray();
        floatArr.Values.AddRange(new[] { 1.0f, 2.0f, 3.0f });
        var protoMsg = new ProtoGenerated.VtqMessage
        {
            Tag = "Motor.Samples",
            Value = new ProtoGenerated.TypedValue
            {
                ArrayValue = new ProtoGenerated.ArrayValue { FloatValues = floatArr }
            },
            TimestampUtcTicks = 638789000000000000L,
            Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
        };

        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));

        codeFirst.Value.Should().NotBeNull();
        codeFirst.Value!.ArrayValue.Should().NotBeNull();
        codeFirst.Value.ArrayValue!.FloatValues.Should().NotBeNull();
        codeFirst.Value.ArrayValue.FloatValues!.Values.Should().BeEquivalentTo(new[] { 1.0f, 2.0f, 3.0f });
    }

    // ── Code-first → Proto-generated ──────────────────────────

    [Fact]
    public void VtqMessage_CodeFirstToProto_DoubleValue()
    {
        var codeFirst = new CodeFirst.VtqMessage
        {
            Tag = "Motor.Speed",
            Value = new CodeFirst.TypedValue { DoubleValue = 99.9 },
            TimestampUtcTicks = 638789000000000000L,
            Quality = new CodeFirst.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
        };

        // Serialize with protobuf-net
        var ms = new MemoryStream();
        Serializer.Serialize(ms, codeFirst);
        var bytes = ms.ToArray();

        // Deserialize with Google.Protobuf
        var protoMsg = ProtoGenerated.VtqMessage.Parser.ParseFrom(bytes);

        protoMsg.Tag.Should().Be("Motor.Speed");
        protoMsg.Value.Should().NotBeNull();
        protoMsg.Value.DoubleValue.Should().Be(99.9);
        protoMsg.TimestampUtcTicks.Should().Be(638789000000000000L);
        protoMsg.Quality.StatusCode.Should().Be(0x00000000u);
    }

    [Fact]
    public void WriteRequest_CodeFirstToProto()
    {
        var codeFirst = new CodeFirst.WriteRequest
        {
            SessionId = "abc123",
            Tag = "Motor.Speed",
            Value = new CodeFirst.TypedValue { DoubleValue = 42.5 }
        };

        var ms = new MemoryStream();
        Serializer.Serialize(ms, codeFirst);
        var bytes = ms.ToArray();

        var protoMsg = ProtoGenerated.WriteRequest.Parser.ParseFrom(bytes);
        protoMsg.SessionId.Should().Be("abc123");
        protoMsg.Tag.Should().Be("Motor.Speed");
        protoMsg.Value.Should().NotBeNull();
        protoMsg.Value.DoubleValue.Should().Be(42.5);
    }

    [Fact]
    public void ConnectRequest_RoundTrips()
    {
        var codeFirst = new CodeFirst.ConnectRequest { ClientId = "ScadaLink-1", ApiKey = "key-123" };
        var ms = new MemoryStream();
        Serializer.Serialize(ms, codeFirst);
        var protoMsg = ProtoGenerated.ConnectRequest.Parser.ParseFrom(ms.ToArray());
        protoMsg.ClientId.Should().Be("ScadaLink-1");
        protoMsg.ApiKey.Should().Be("key-123");
    }

    [Fact]
    public void ConnectResponse_RoundTrips()
    {
        var protoMsg = new ProtoGenerated.ConnectResponse
        {
            Success = true,
            Message = "Connected",
            SessionId = "abcdef1234567890abcdef1234567890"
        };
        var bytes = protoMsg.ToByteArray();
        var codeFirst = Serializer.Deserialize<CodeFirst.ConnectResponse>(new MemoryStream(bytes));
        codeFirst.Success.Should().BeTrue();
        codeFirst.Message.Should().Be("Connected");
        codeFirst.SessionId.Should().Be("abcdef1234567890abcdef1234567890");
    }

    [Fact]
    public void WriteBatchAndWaitRequest_CodeFirstToProto_TypedFlagValue()
    {
        var codeFirst = new CodeFirst.WriteBatchAndWaitRequest
        {
            SessionId = "sess1",
            FlagTag = "Motor.Done",
            FlagValue = new CodeFirst.TypedValue { BoolValue = true },
            TimeoutMs = 5000,
            PollIntervalMs = 100,
            Items =
            {
                new CodeFirst.WriteItem
                {
                    Tag = "Motor.Speed",
                    Value = new CodeFirst.TypedValue { DoubleValue = 50.0 }
                }
            }
        };

        var ms = new MemoryStream();
        Serializer.Serialize(ms, codeFirst);
        var protoMsg = ProtoGenerated.WriteBatchAndWaitRequest.Parser.ParseFrom(ms.ToArray());

        protoMsg.FlagTag.Should().Be("Motor.Done");
        protoMsg.FlagValue.BoolValue.Should().BeTrue();
        protoMsg.TimeoutMs.Should().Be(5000);
        protoMsg.PollIntervalMs.Should().Be(100);
        protoMsg.Items.Should().HaveCount(1);
        protoMsg.Items[0].Tag.Should().Be("Motor.Speed");
        protoMsg.Items[0].Value.DoubleValue.Should().Be(50.0);
    }
}
```

**Important note on protobuf-net oneof handling**: protobuf-net's code-first approach does not have native `oneof` support. Instead, it serializes whichever fields have non-default values. For `TypedValue`, only one field should be set at a time to maintain oneof semantics. The cross-stack tests verify this works in practice. If tests fail due to protobuf-net serializing multiple fields when only one is set (e.g., `BoolValue = false` serialized as field 1 with value 0), you may need to add `[ProtoMember]` attributes with `ShouldSerialize*` methods or use `[ProtoContract(SkipConstructor = true)]`. Investigate and fix if needed — the cross-stack tests exist precisely to catch these issues.

---

## Step 9: Build verification

Run these commands from the repository root (`/Users/dohertj2/Desktop/scadalink-design/lmxproxy`):

```bash
# Verify solution builds (Host will likely fail on macOS due to net48/x86 — that's expected)
dotnet build src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj

# Run Client tests (these should work on macOS)
dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj

# If on Windows, also run:
# dotnet build src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj
# dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj
```

**Note on build platform**: The Host project targets `net48` with `PlatformTarget=x86`, which requires Windows with .NET Framework 4.8 SDK. On macOS, only the Client project and Client.Tests will build. The Host will be verified on the Windows development machine (windev). The cross-stack serialization tests in Client.Tests use the proto file directly (via `Grpc.Tools` codegen which works on all platforms for message-only generation) so they can run on macOS.

---

## Completion Criteria

- [ ] `src-reference/` contains old code, `src/` contains fresh v2 code only
- [ ] Solution file references all 4 projects (Host, Client, Host.Tests, Client.Tests)
- [ ] Proto file at `src/ZB.MOM.WW.LmxProxy.Host/Grpc/Protos/scada.proto` matches v2 spec exactly
- [ ] Host domain types compile: Quality, QualityExtensions, Vtq, ConnectionState, ConnectionStateChangedEventArgs, IScadaClient
- [ ] Client domain types compile: Quality, QualityExtensions, Vtq, ConnectionState, ScadaContracts (all v2 messages)
- [ ] TypedValueConverter handles all COM variant types from the coercion table
- [ ] QualityCodeMapper maps all AVEVA-relevant quality codes bidirectionally
- [ ] All Host.Tests pass (TypedValueConverter, QualityCodeMapper, QualityExtensions)
- [ ] All Client.Tests pass (ScadaContracts, QualityExtensions, Vtq factory methods)
- [ ] Cross-stack serialization tests pass (proto-generated ↔ code-first)
- [ ] No references to `ZB.MOM.WW.ScadaBridge` or `ZB.MOM.WW.Lmx.Proxy` in any new file
- [ ] No string serialization heuristics (`double.TryParse`, `bool.TryParse`) in any new file
