# Configuration Reference

`mbproxy` binds its runtime configuration from `appsettings.json` under the `Mbproxy` section. This document is the full reference for every supported key, its type, default, range, and validation rules.

## File Location

The configuration loader resolves `appsettings.json` relative to the executable.

- **Development run** (`dotnet run`): `src/Mbproxy/appsettings.json` next to the build output.
- **Single-file publish** (`dotnet publish -c Release -r <rid>`): `appsettings.json` next to the published binary. A `win-x64` publish ships `install/mbproxy.config.template.json`; a `linux-x64` publish ships `install/mbproxy.linux.config.template.json` (same keys, Unix log path) — each linked into the bundle as `appsettings.json`.
- **Installed as a Windows Service**: `%ProgramData%\mbproxy\appsettings.json`, seeded by `install.ps1` from `mbproxy.config.template.json`.
- **Installed as a systemd unit**: `/etc/mbproxy/appsettings.json` (the unit's `WorkingDirectory`), seeded by `install.sh` from the Linux template.

In both installed cases the install script copies the template only when no config already exists — an existing file is preserved across reinstalls.

The file is loaded with `reloadOnChange: true`. All consumers read through `IOptionsMonitor<MbproxyOptions>`, so a save propagates without restarting the service. See [`../Features/HotReload.md`](../Features/HotReload.md) for per-key propagation semantics.

The .NET configuration provider accepts `//` and `/* */` comments (JSONC) in `appsettings.json` when loaded through `Host.CreateApplicationBuilder`. The install template ships with comments.

Environment variables and command-line arguments are also accepted by the host. Either form can override any `Mbproxy:*` key; for example, `Mbproxy__AdminPort=9090` (double-underscore segment separator) overrides the JSON. Environment overrides are useful for ephemeral diagnostic switches but should not replace the file as the source of truth — `ReloadValidator` runs against the merged configuration on every reload.

## Top-Level Schema

Every supported key under `Mbproxy:*`, populated to a representative default:

```jsonc
{
  "Mbproxy": {

    // Global BCD tag list — applies to every PLC unless overridden per-PLC.
    "BcdTags": {
      "Global": [
        { "Address": 1024, "Width": 16 },                       // 16-bit BCD register
        { "Address": 1056, "Width": 32 },                       // 32-bit BCD pair (CDAB)
        { "Address": 1088, "Width": 16, "CacheTtlMs": 1000 }    // opt-in cache, 1 s TTL
      ]
    },

    // One entry per PLC. Each maps an upstream proxy port to a backend Modbus TCP endpoint.
    "Plcs": [
      {
        "Name": "Line1-Mixer",
        "ListenPort": 5020,
        "Host": "10.0.1.1",
        "Port": 502,
        "DefaultCacheTtlMs": 0,
        "BcdTags": {
          "Add":    [ { "Address": 1200, "Width": 32 } ],
          "Remove": [ 1056 ]
        }
      }
    ],

    // Read-only HTTP status page. Set to 0 to disable.
    "AdminPort": 8080,

    // Backend connection / request / shutdown timeouts and keepalive.
    "Connection": {
      "BackendConnectTimeoutMs":   3000,
      "BackendRequestTimeoutMs":   3000,
      "GracefulShutdownTimeoutMs": 10000,
      "Keepalive": {
        "Enabled":                      true,
        "TcpIdleTimeMs":                30000,
        "TcpProbeIntervalMs":           5000,
        "TcpProbeCount":                4,
        "BackendHeartbeatIdleMs":       30000,
        "BackendHeartbeatProbeAddress": 0
      }
    },

    // Polly resilience policies.
    "Resilience": {
      "BackendConnect": {
        "MaxAttempts": 3,
        "BackoffMs":   [ 100, 500, 2000 ]
      },
      "ListenerRecovery": {
        "InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ],
        "SteadyStateMs":    30000
      },
      "ReadCoalescing": {
        "Enabled":    true,
        "MaxParties": 32
      }
    },

    // Response-cache safety knobs. The cache is off by default per tag.
    "Cache": {
      "AllowLongTtl":       false,
      "MaxEntriesPerPlc":   1000,
      "EvictionIntervalMs": 5000
    }
  }
}
```

`Serilog` configuration is documented in [`./Troubleshooting.md`](./Troubleshooting.md) and lives outside the `Mbproxy` section.

> The Windows Event Log sink is **not** the standard `Serilog.Sinks.EventLog` package. It is a custom `EventLogBridge` (`src/Mbproxy/Diagnostics/EventLogBridge.cs`) that writes Error+ events to the `mbproxy` source under `Application` only when the service runs under the SCM. Event Log source registration is intentionally NOT attempted at runtime (the service account may not be admin); `install.ps1` registers the source at install time. Don't add `Serilog.Sinks.EventLog` — the bridge would duplicate every event. The bridge caches the source-exists check at construction (Phase 12 / W2.23), so a missing source produces no per-event registry traffic.

## `Mbproxy.AdminPort`

Port for the read-only HTTP status server. Binds to all interfaces on startup.

| Field | Type | Default | Range |
|-------|------|---------|-------|
| `AdminPort` | int | `8080` | `[1, 65535]` |

`ReloadValidator` rejects values outside `[1, 65535]` and rejects collisions with any `Plcs[i].ListenPort`. Source: `MbproxyOptions.AdminPort`.

The server exposes the SignalR-backed web dashboard (`GET /`, `GET /plc/{name}`, `GET /assets/{path}`, `/hub/status`) and the JSON twin `GET /status.json`. See [`./StatusPage.md`](./StatusPage.md) for the endpoint surface and schema.

Authentication is assumed at the network layer (trusted internal segment). The endpoint is read-only — no admin actions are exposed — so the risk surface is limited to status disclosure. Place the admin port behind a firewall rule that allows only operator workstations.

## `Mbproxy.AdminPushIntervalMs`

Server-push cadence (milliseconds) for the admin dashboard's SignalR feed. Every interval `StatusBroadcaster` builds a status snapshot and pushes it to connected dashboard / detail-page clients.

| Field | Type | Default | Range |
|-------|------|---------|-------|
| `AdminPushIntervalMs` | int | `1000` | `> 0` |

`MbproxyOptionsValidator` and `ReloadValidator` both reject values `<= 0`. The broadcaster additionally floors the effective interval at 100 ms. Source: `MbproxyOptions.AdminPushIntervalMs`.

## `Mbproxy.Plcs[]`

One entry per PLC. The array drives the listener supervisor; on reload, entries added here cause new listeners to bind and entries removed here cause listeners to stop. Source: `PlcOptions.cs`.

| Field | Type | Default | Required | Notes |
|-------|------|---------|----------|-------|
| `Name` | string | `""` | yes | Non-empty, unique across the array. Shown on the status page and in structured logs as `plc`. |
| `ListenPort` | int | `0` | yes | Port the proxy listens on. `[1, 65535]`. Unique across the array. Cannot collide with `AdminPort`. |
| `Host` | string | `""` | yes | PLC IP address or hostname. |
| `Port` | int | `502` | no | Backend Modbus TCP port on the PLC. |
| `BcdTags` | object | `null` | no | Per-PLC overrides on top of `Mbproxy.BcdTags.Global`. See below. |
| `DefaultCacheTtlMs` | int | `0` | no | Fallback TTL in milliseconds for any tag on this PLC whose explicit `CacheTtlMs` is unset (`null`). `0` disables caching by default. |

### `Plcs[i].BcdTags`

Per-PLC override block. Resolution: the effective tag list for a PLC is `Global ∪ Add − Remove`, with `Add` winning on width when an address appears in both `Global` and `Add`. Source: `BcdTagListOptions.PlcBcdOverrides`.

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `Add` | `BcdTagOptions[]` | `[]` | Tags to append for this PLC. Can override a `Global` entry's `Width` by repeating the address. Each entry follows the `BcdTagOptions` shape (see next section). |
| `Remove` | `ushort[]` | `[]` | Addresses to drop from this PLC's effective list. Matches by address. |

The full tag-list resolution algorithm — `Add` width override semantics, overlap detection, and per-PLC tag-map flushing on reload — is documented in [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md).

A subtle case worth pinning down: when an address appears in both `Mbproxy.BcdTags.Global[]` and `Plcs[i].BcdTags.Add[]`, the per-PLC `Add` entry wins on `Width` and `CacheTtlMs`. This is how a per-PLC width override is expressed (for example, a 16-bit tag globally, promoted to 32-bit on the one PLC that uses the wider format). To strip a global tag from a PLC entirely, use `Remove`; do not add a same-address entry with `Width = 0`.

## `Mbproxy.BcdTags.Global[]`

The fleet-wide BCD tag list. Every PLC starts with this set, then applies its per-PLC `Add` / `Remove` overrides. Source: `BcdTagListOptions.Global`, entries of type `BcdTagOptions`.

| Field | Type | Default | Range | Notes |
|-------|------|---------|-------|-------|
| `Address` | ushort | `0` | `[0, 65535]` | Modbus PDU address (decimal). Address `0` is valid on DL205/DL260 — do not skip it. Octal V-memory addresses must be converted: `V2000` octal = decimal `1024`. |
| `Width` | byte | `0` | `{ 16, 32 }` | Bit width. `16` is one register holding 4 BCD digits (`0–9999`). `32` is a CDAB-ordered register pair at `Address` (low word) and `Address+1` (high word). |
| `CacheTtlMs` | int? | `null` | `>= 0`, `<= 60000` unless `Cache.AllowLongTtl = true` | Optional per-tag opt-in to the response cache. `null` falls back to the PLC's `DefaultCacheTtlMs`. `0` explicitly disables caching for this tag even when the PLC default is non-zero. |

`MbproxyOptionsValidator` rejects any entry whose `Width` is not `16` or `32`. See [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md) for the wire encoding rules and the multi-tag-overlap validation that runs in `BcdTagMapBuilder`.

Address conversion examples for operators coming from DirectLOGIC ladder:

| V-memory (octal) | Modbus PDU (decimal) |
|------------------|----------------------|
| `V2000` | `1024` |
| `V2040` | `1056` |
| `V2100` | `1088` |
| `V2200` | `1152` |

The proxy expects PDU-decimal addresses. Do not use octal V-memory addresses and do not use 1-based `4xxxx` Modbus references — both will resolve to the wrong register.

## `Mbproxy.Connection`

Backend connection and shutdown timeouts. Source: `ConnectionOptions.cs`.

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `BackendConnectTimeoutMs` | int | `3000` | Max time in milliseconds to wait for one TCP connect to the backend PLC. Each Polly retry attempt is bounded by its own copy of this timeout — total worst-case connect time is `MaxAttempts * BackendConnectTimeoutMs` plus the configured backoffs. |
| `BackendRequestTimeoutMs` | int | `3000` | Max time in milliseconds to wait for the PLC to respond to a forwarded PDU. On timeout the upstream client is disconnected. FC06 / FC16 writes are not retried because they are non-idempotent on BCD tags; FC03 / FC04 reads are also not retried mid-request (a fresh upstream request takes the full pipeline again). |
| `GracefulShutdownTimeoutMs` | int | `10000` | Max time in milliseconds the shutdown coordinator waits for in-flight PDUs to drain after a stop signal (`sc.exe stop` or Windows Service stop). After this deadline, remaining work is cancelled. Keep at or below the Service Control Manager wait hint (30 s). |

On hot reload, `BackendConnectTimeoutMs` and `BackendRequestTimeoutMs` apply to the next backend connect or request — in-flight operations keep their already-applied timeout. `GracefulShutdownTimeoutMs` is sampled only at shutdown.

Operational sizing notes:

- The default 3 s connect timeout is appropriate for a local Ethernet segment to a healthy ECOM100. On WAN paths or for devices behind switches with slow MAC-table aging, raise to 5–10 s.
- A 3 s request timeout is generous compared with typical DL205/DL260 scan times (a few ms to tens of ms for FC03 of 100 registers). The slack absorbs PLC scan-overlap jitter without faulting the upstream client.
- `GracefulShutdownTimeoutMs` should be less than the Service Control Manager's stop deadline. The default 10 s suits a fleet of 54 PLCs; on a much larger fleet, raise both the SCM wait hint and this value in lockstep.

## `Mbproxy.Connection.Keepalive`

TCP keepalive and backend heartbeat settings. Source: `KeepaliveOptions.cs`. Enabled by default — the DL205/DL260 ECOM never emits TCP keepalives, so an idle socket is otherwise dropped by middleboxes after 2–5 minutes. See [`../Architecture/Keepalive.md`](../Architecture/Keepalive.md) for the full design.

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `Enabled` | bool | `true` | Master switch. When `false`, neither `SO_KEEPALIVE` nor the backend heartbeat is applied and the proxy behaves exactly as a pre-keepalive build. |
| `TcpIdleTimeMs` | int | `30000` | `SO_KEEPALIVE` idle time before the OS sends its first probe. Applied to the backend socket and accepted upstream sockets. |
| `TcpProbeIntervalMs` | int | `5000` | `SO_KEEPALIVE` interval between probes once idle. |
| `TcpProbeCount` | int | `4` | `SO_KEEPALIVE` unanswered probes before the OS declares the socket dead. |
| `BackendHeartbeatIdleMs` | int | `30000` | After this much backend idle, the proxy issues a synthetic FC03 qty=1 read to keep the path warm and prove the ECOM still answers Modbus. Must be greater than `BackendRequestTimeoutMs`. |
| `BackendHeartbeatProbeAddress` | int | `0` | Modbus PDU address the heartbeat FC03 probe reads. Address `0` (`V0`) is valid on DL205/DL260 in factory absolute mode. Range `[0, 65535]`. |

On hot reload, the heartbeat interval and probe address are re-read on every loop tick. The `Tcp*` socket options are applied at connect/accept time, so a reload affects only sockets opened after the change. A reload where `BackendHeartbeatIdleMs <= BackendRequestTimeoutMs` is rejected — a heartbeat interval at or below the request timeout would fire continuously.

## `Mbproxy.Resilience`

Polly retry pipelines for backend connect, listener bind, and the in-flight read coalescer. Source: `ResilienceOptions.cs`.

### `Mbproxy.Resilience.BackendConnect`

Bounded retries on the backend TCP connect path. Mid-request failures (during a forwarded PDU) are never retried.

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `MaxAttempts` | int | `3` | Total connect tries, including the first. `1` disables retries. |
| `BackoffMs` | int[] | `[100, 500, 2000]` | Delay in milliseconds between attempts. Must have `MaxAttempts - 1` entries. |

### `Mbproxy.Resilience.ListenerRecovery`

Unbounded retries on the listener bind path. If a PLC's `ListenPort` cannot be bound (port in use, bad interface, transient OS error), the supervisor cycles through `InitialBackoffMs` once, then repeats `SteadyStateMs` forever. The same recovery code path also reacts to a listener that faults at runtime (for example, the underlying socket dies) and to listeners that come online from a hot-reload that adds a new PLC.

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `InitialBackoffMs` | int[] | `[1000, 2000, 5000, 15000, 30000]` | Backoff schedule for the first N retries after a fault. |
| `SteadyStateMs` | int | `30000` | Backoff for every retry after the initial schedule is exhausted. Runs indefinitely. |

### `Mbproxy.Resilience.ReadCoalescing`

In-flight de-duplication of identical FC03 / FC04 reads. When multiple upstream clients issue the same `(unitId, fc, startAddress, qty)` tuple while a matching backend round-trip is already in flight, the late arrivals attach to the existing entry and the single response is fanned out to every party. See [`../Architecture/ReadCoalescing.md`](../Architecture/ReadCoalescing.md).

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `Enabled` | bool | `true` | Master switch. Hot-reloadable; flipping to `false` lets already-coalesced entries drain naturally. |
| `MaxParties` | int | `32` | Per-entry cap on attached parties. Past this cap, the next identical request opens a fresh in-flight entry. |

Writes (FC06 / FC16) are never coalesced. FC03 and FC04 never share an entry. Different `unitId` bytes never share an entry.

Total FC03 + FC04 request accounting is preserved across the coalescing path: `coalescedHitCount + coalescedMissCount` equals the total reads observed by the multiplexer since startup. `coalescedHitCount` stays at `0` while `Enabled = false`, but every read still increments `coalescedMissCount`. See [`./StatusPage.md`](./StatusPage.md) for the full counter catalogue.

## `Mbproxy.Cache`

Service-wide safety knobs for the opt-in response cache. The cache is off by default per tag — this section only governs the limits when an operator opts a tag in via `CacheTtlMs` or `DefaultCacheTtlMs`. Source: `CacheOptions` in `MbproxyOptions.cs`.

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `AllowLongTtl` | bool | `false` | Gate for any `CacheTtlMs > 60_000`. When `false`, `ReloadValidator` rejects any tag or PLC default that exceeds 60 s. Set to `true` to opt in explicitly. |
| `MaxEntriesPerPlc` | int | `1000` | LRU cap on the number of entries per PLC. When full, the next insert evicts the least-recently-used entry. Must be `>= 0`. `0` is accepted but means "evict every insert immediately" — effectively the cache is disabled even for tags with non-zero TTL. |
| `EvictionIntervalMs` | int | `5000` | Background eviction loop tick in milliseconds. Each tick scans the per-PLC caches and removes entries past their `ExpiresAtUtc`. Must be `>= 0`; values below 100 ms are clamped at 100 ms internally to avoid pathologically tight loops. |

On hot reload, `AllowLongTtl` is enforced by the next reload validation. `MaxEntriesPerPlc` applies to subsequent inserts (existing entries are not pruned). `EvictionIntervalMs` is read by each fresh eviction loop iteration.

Any tag-list change for a given PLC drops that PLC's entire cache on reload — per-tag flush granularity is intentionally not implemented. New entries re-populate on demand under the new TTL. Process restart wipes every cache; there is no persistence and no last-known-good snapshot.

See [`../Architecture/ResponseCache.md`](../Architecture/ResponseCache.md) for the cache contract (lookup order, write-invalidation by address-range overlap, post-rewriter byte storage).

## Per-Tag `CacheTtlMs`

Per-tag opt-in to the cache. The same field appears on every `BcdTagOptions` entry — both `Mbproxy.BcdTags.Global[]` and `Mbproxy.Plcs[i].BcdTags.Add[]`.

| Value | Meaning |
|-------|---------|
| `null` (omitted) | Unset. Falls back to `Plcs[i].DefaultCacheTtlMs`. |
| `0` | Caching explicitly disabled for this tag, even if the PLC default is non-zero. |
| `1..60000` | Cache enabled with this TTL in milliseconds. |
| `> 60000` | Rejected at reload unless `Cache.AllowLongTtl = true`. |

TTL resolution order for any single tag: **explicit per-tag value → per-PLC `DefaultCacheTtlMs` → 0 (off)**.

For multi-tag read ranges, the effective TTL is `min(TTLs)` across all configured tags inside the read range. If any tag in the range has `CacheTtlMs = 0`, the entire read is uncached.

The cache itself is described in detail in [`../Architecture/ResponseCache.md`](../Architecture/ResponseCache.md). The properties most relevant to operators setting TTLs:

- **Lookup order is cache → coalesce → backend.** A cache hit short-circuits the read coalescer entirely.
- **Writes invalidate by address-range overlap.** A successful FC06 / FC16 response invalidates every cached FC03 / FC04 entry whose read range overlaps the write range — not just exact-key matches. Exception responses do not invalidate (the write did not take effect on the PLC).
- **Cache stores post-rewriter bytes.** Hits never re-invoke the BCD rewriter. Tag-list reloads flush the affected PLC's whole cache so a rewriter-relevant change cannot serve stale post-rewriter bytes from before the change.
- **Different `unitId` bytes never invalidate each other.** Invalidation is scoped to `(unitId, FC ∈ {3, 4})`.

## Validation Rules

`ReloadValidator.Validate` runs on every config load (startup and hot reload) and rejects the entire snapshot if any rule fails. On rejection at startup, the service exits non-zero. On rejection at runtime, the current in-memory config stays in effect and `mbproxy.config.reload.rejected` is logged at `Error`.

Rules (in order):

1. **PLC names**: every `Plcs[i].Name` is non-empty and unique (ordinal comparison).
2. **ListenPort**: every `Plcs[i].ListenPort` is in `[1, 65535]` and unique across the array.
3. **AdminPort**: in `[1, 65535]` and does not collide with any `ListenPort`.
4. **BCD tag map** per PLC, delegated to `BcdTagMapBuilder.Build`:
   - duplicate addresses within a single PLC's resolved tag list
   - 32-bit entries whose high register (`Address + 1`) overlaps a separate 16-bit entry at that address
5. **Cache TTL bounds**:
   - any `CacheTtlMs` or `DefaultCacheTtlMs` less than 0 is rejected
   - any `CacheTtlMs` or `DefaultCacheTtlMs` greater than `60_000` is rejected unless `Cache.AllowLongTtl = true`
6. **Cache size knobs**: `Cache.MaxEntriesPerPlc >= 0`, `Cache.EvictionIntervalMs >= 0`.
7. **Width**: every `BcdTagOptions.Width` is `16` or `32` (enforced by `MbproxyOptionsValidator` at schema time).

Sample rejection messages (logged at `Error` with the structured property `errors` carrying the full list):

```text
Plc 'Line1-Mixer': Duplicate ListenPort 5020 (already used by 'Line1-Conveyor').
AdminPort 5020 collides with ListenPort of PLC 'Line1-Mixer'.
Plc 'Line1-Mixer': BCD tag map error (DuplicateAddress): address 1024 appears twice.
BcdTags.Global Address 1024: CacheTtlMs=120000 exceeds 60_000 ms without Cache.AllowLongTtl=true.
Plcs[2] (Line2-Press): DefaultCacheTtlMs must be >= 0.
```

Warning case (not a rejection):

- `Plcs[i].BcdTags.Remove[]` entries that do not match any global tag address are logged as warnings — probably stale config, but the reload proceeds.

Two additional rejection categories handled earlier in the pipeline:

- **Type-mismatched / malformed JSON.** The .NET configuration binder rejects values whose type does not match the bound property (for example, a string in `BackendConnectTimeoutMs`). At startup this aborts the host; on hot reload the binder retains the previous snapshot and the reload never reaches `ReloadValidator`.
- **Width invalid.** `MbproxyOptionsValidator` rejects any `BcdTagOptions.Width` that is not `16` or `32`. This runs as part of options validation before `ReloadValidator` and surfaces the same way as schema errors.

See [`../Features/HotReload.md`](../Features/HotReload.md) for the full reload-acceptance flow, including the log event names emitted on acceptance (`mbproxy.config.reload.applied`) and rejection (`mbproxy.config.reload.rejected`).

## Two Concrete Examples

The minimal and production examples below are both complete `appsettings.json` snippets — paste either one and the service will start without further edits beyond the addresses and ports.

### Minimal

One PLC, no BCD tags, no cache. The proxy is pure pass-through.

```jsonc
{
  "Mbproxy": {
    "BcdTags": { "Global": [] },
    "Plcs": [
      {
        "Name":       "Line1-Mixer",
        "ListenPort": 5020,
        "Host":       "10.0.1.1"
      }
    ],
    "AdminPort": 8080
  }
}
```

Everything else picks up defaults: `Port = 502`, `Connection.BackendConnectTimeoutMs = 3000`, `Connection.BackendRequestTimeoutMs = 3000`, `Connection.GracefulShutdownTimeoutMs = 10000`, `Resilience.BackendConnect.MaxAttempts = 3`, `Resilience.ReadCoalescing.Enabled = true`, `Cache.AllowLongTtl = false`, `Cache.MaxEntriesPerPlc = 1000`, `Cache.EvictionIntervalMs = 5000`, and so on.

Behaviour in this snapshot: every byte passes through unchanged in both directions, FC03 / FC04 reads are still subject to in-flight coalescing (the feature is on by default), and no responses are cached.

### Production

Three PLCs, a global BCD tag list, one PLC with overrides, cache enabled on hot reads.

```jsonc
{
  "Mbproxy": {
    "BcdTags": {
      "Global": [
        { "Address": 1024, "Width": 16 },                        // V2000 — 16-bit BCD counter
        { "Address": 1056, "Width": 32 },                        // V2040 — 32-bit BCD total
        { "Address": 1088, "Width": 16, "CacheTtlMs": 1000 }     // V2100 — setpoint, 1 s cache
      ]
    },
    "Plcs": [
      {
        "Name":              "Line1-Mixer",
        "ListenPort":        5020,
        "Host":              "10.0.1.1",
        "Port":              502,
        "DefaultCacheTtlMs": 0,
        "BcdTags": {
          "Add":    [ { "Address": 1200, "Width": 32 } ],
          "Remove": [ 1056 ]
        }
      },
      {
        "Name":       "Line1-Conveyor",
        "ListenPort": 5021,
        "Host":       "10.0.1.2"
      },
      {
        "Name":              "Line2-Press",
        "ListenPort":        5022,
        "Host":              "10.0.2.1",
        "DefaultCacheTtlMs": 500
      }
    ],
    "AdminPort": 8080,
    "Connection": {
      "BackendConnectTimeoutMs":   3000,
      "BackendRequestTimeoutMs":   3000,
      "GracefulShutdownTimeoutMs": 10000
    },
    "Resilience": {
      "BackendConnect":   { "MaxAttempts": 3, "BackoffMs": [ 100, 500, 2000 ] },
      "ListenerRecovery": { "InitialBackoffMs": [ 1000, 2000, 5000, 15000, 30000 ], "SteadyStateMs": 30000 },
      "ReadCoalescing":   { "Enabled": true, "MaxParties": 32 }
    },
    "Cache": {
      "AllowLongTtl":       false,
      "MaxEntriesPerPlc":   1000,
      "EvictionIntervalMs": 5000
    }
  }
}
```

In this snapshot, `Line1-Mixer` adds a 32-bit tag at `1200` and removes the global 32-bit tag at `1056`. `Line2-Press` opts every tag in (whose `CacheTtlMs` is `null`) into a 500 ms cache via its `DefaultCacheTtlMs`. The setpoint at `1088` already has an explicit per-tag TTL and that value wins.

The effective tag map per PLC after resolution:

| PLC | Effective tag list |
|-----|--------------------|
| `Line1-Mixer` | `1024` (16-bit), `1088` (16-bit, `CacheTtlMs = 1000`), `1200` (32-bit). `1056` is removed. |
| `Line1-Conveyor` | `1024` (16-bit), `1056` (32-bit), `1088` (16-bit, `CacheTtlMs = 1000`). |
| `Line2-Press` | `1024` (16-bit, effective `CacheTtlMs = 500` via PLC default), `1056` (32-bit, effective `CacheTtlMs = 500`), `1088` (16-bit, effective `CacheTtlMs = 1000` from explicit per-tag value). |

Any FC03 / FC04 read whose register range overlaps `Line2-Press`'s tag `1088` resolves to the per-tag 1 s TTL. A read that spans tags with different TTLs takes `min(TTLs)` across the range; a read that includes a tag with `CacheTtlMs = 0` is uncached even if every other tag in the range is opted in.

## Hot-Reload Propagation Summary

A reduced view of [`../Features/HotReload.md`](../Features/HotReload.md), restricted to the keys documented here. Every accepted reload emits `mbproxy.config.reload.applied` at `Information` with a summary of which PLCs were added or removed and the size of the tag-list delta.

| Change | Propagation |
|--------|-------------|
| `BcdTags.Global` add / remove / width | Rewriter dereferences `IOptionsMonitor` per PDU. Next PDU sees the new map; in-flight PDUs are not retroactively touched. |
| `Plcs[i].BcdTags.{Add,Remove}` | Same per-PDU resolution as above, scoped to the affected PLC. |
| New `Plcs[i]` entry | Listener supervisor binds the new port under `ListenerRecovery`. |
| `Plcs[i]` removed | Supervisor stops the listener and closes all upstream connections for that PLC. |
| `Plcs[i].ListenPort` or `Host` changed | Equivalent to remove + add. |
| `Connection.Backend*TimeoutMs` | Next backend connect or request uses the new value. |
| `Connection.Keepalive` heartbeat fields | Re-read on every heartbeat loop tick. `Tcp*` socket options apply to backend/upstream sockets opened after the change. |
| `AdminPort` | Requires a service restart — the Kestrel admin host is built once at startup. |
| `Resilience.ReadCoalescing.Enabled` | Hot-reloadable; in-flight coalesced entries drain naturally. |
| `BcdTags.*.CacheTtlMs`, `Plcs[i].DefaultCacheTtlMs` | Tag-map reseat for the affected PLC drops that PLC's entire cache. |
| `Cache.AllowLongTtl` / `MaxEntriesPerPlc` / `EvictionIntervalMs` | Enforced on next reload validation / next insert / next eviction tick respectively. |

## Where Options Live in Code

| Section | File | Binding class |
|---------|------|---------------|
| Root | `src/Mbproxy/Options/MbproxyOptions.cs` | `MbproxyOptions` |
| `Plcs[]` | `src/Mbproxy/Options/PlcOptions.cs` | `PlcOptions` |
| `BcdTags.Global[]` entry shape | `src/Mbproxy/Options/BcdTagOptions.cs` | `BcdTagOptions` |
| `BcdTags.Global` / `Plcs[i].BcdTags` | `src/Mbproxy/Options/BcdTagListOptions.cs` | `BcdTagListOptions`, `PlcBcdOverrides` |
| `Connection` | `src/Mbproxy/Options/ConnectionOptions.cs` | `ConnectionOptions` |
| `Resilience` | `src/Mbproxy/Options/ResilienceOptions.cs` | `ResilienceOptions`, `RetryProfile`, `RecoveryProfile`, `ReadCoalescingOptions` |
| `Cache` | `src/Mbproxy/Options/MbproxyOptions.cs` | `CacheOptions` (declared alongside `MbproxyOptions` in the same file) |
| Schema validation | `src/Mbproxy/Options/MbproxyOptions.cs` | `MbproxyOptionsValidator` |
| Reload validation | `src/Mbproxy/Configuration/ReloadValidator.cs` | `ReloadValidator` |
| Tag-map resolution | `src/Mbproxy/Bcd/BcdTagMapBuilder.cs` | `BcdTagMapBuilder` |
| Reload reconciliation | `src/Mbproxy/Configuration/ConfigReconciler.cs` | `ConfigReconciler`, `ReloadPlan` |

All option classes are registered through `services.Configure<T>(...)` against the `Mbproxy:*` section in `Program.cs`. `IOptionsMonitor<T>` is the runtime read path; direct `IOptions<T>` injection is not used because it does not propagate reloads.

## Related Documentation

- [`../Features/HotReload.md`](../Features/HotReload.md) — reload acceptance flow and per-key propagation semantics
- [`../Features/BcdRewriting.md`](../Features/BcdRewriting.md) — tag-list resolution, wire encoding, multi-tag overlap rules
- [`../Architecture/ResponseCache.md`](../Architecture/ResponseCache.md) — cache contract, lookup order, write invalidation
- [`./StatusPage.md`](./StatusPage.md) — schema served by `AdminPort`
- [`./Troubleshooting.md`](./Troubleshooting.md) — Serilog block and common config rejection diagnostics
- [`../../README.md`](../../README.md) — install and operational entry point
