# Design + Implementation Plan — R2-11: TagConfig parse consolidation + driver parse-strictness unification

- **Source reports:** `archreview/01-core-composition.md` (C-1, P-1) + `archreview/05-protocol-drivers.md` (CONV-2, UNDER-1 parser seam, UNDER-6) — 2026-07-12 re-review (working-tree versions)
- **Action item:** #11 in `archreview/00-OVERALL.md` ("TagConfig parse consolidation (`TagConfigIntent.Parse` in Commons) + `ReadEnum` unification — 01 C-1/P-1, 05 — Medium/Medium")
- **Round-1 plans unified:** `archreview/plans/01-core-composition-plan.md` §2 (C-1) + §"P-1", and `archreview/plans/05-protocol-drivers-plan.md` Part B §B2
- **Plan verified against tree at:** `f6eaa267` (master, clean working tree except archreview/ doc updates)
- **Scope:** `Commons`, `Core.Abstractions`, `Configuration`, `Core` (walker), `OpcUaServer` (composer), `Runtime` (artifact), the six protocol-driver `.Contracts` projects + driver hosts, `ControlPlane` (deploy gate), `AdminUI` (typed editors — sync only)

## Verification summary

Every cited finding was re-opened at the working-tree source. **All CONFIRMED — none stale.** The re-review's C-1/P-1 status ("STILL OPEN — all four copies + comments intact") matches the tree.

### The four byte-parity TagConfig parse sites (01/C-1)

| # | Project | File / members | Notes |
|---|---|---|---|
| 1 | `ZB.MOM.WW.OtOpcUa.OpcUaServer` | `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — `ExtractTagFullName` (:536-551), `TryExtractDeviceHost` (~:567) + `NormalizeDeviceHost`, `ExtractTagAlarm` / `ExtractTagHistorize` / `ExtractTagArray` (:601-686; doc-comment "MUST parse identically" at :598/:630/:662) | The **canonical copy**. Called 4× per tag from the equipment-tag `Select` (:417-433) — this is P-1's 4 `JsonDocument.Parse` per tag (:541/:606/:638/:670). `EquipmentTagAlarmInfo` record defined here (:146). |
| 2 | `ZB.MOM.WW.OtOpcUa.Runtime` | `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — private mirrors `ExtractTagFullName` (~:668-689), `ExtractTagAlarm` (comment :693), `ExtractTagHistorize` (:723), `ExtractTagArray` (:751) | Artifact-decode mirror; each carries the "MUST parse identically (byte-parity)" comment. Also calls `AddressSpaceComposer.TryExtractDeviceHost` cross-project at :837 (device-host is already single-sourced, just housed in the wrong project). |
| 3 | `ZB.MOM.WW.OtOpcUa.Core` | `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs:193-208` — `internal static ExtractFullName` | Dormant path (01/U-1); FullName-only copy. Multiple tree-wide doc comments cite it as "the canonical parser". |
| 4 | `ZB.MOM.WW.OtOpcUa.Configuration` | `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs:57-71` — private `ExtractTagConfigFullName` | FullName-only copy with **different semantics**: returns `null` on absent (explicit-FullName check for the Galaxy rule at :41-56), not the raw-blob fallback. Any consolidation must preserve this distinction. |

Re-review note: #13 added a fifth *parity-coupled* read (`DeploymentArtifact.TryReadSpec` for `ResilienceConfig`, :168-181) — a trivial null-safe string passthrough with no intent parsing. **Out of scope here** (no consolidation value; noted so nobody "helpfully" folds it in).

### The `ReadEnum` census (05/CONV-2)

Six byte-identical private copies of `ReadEnum<TEnum>(JsonElement, string, TEnum fallback)` — silent fallback on present-but-invalid values (a typo'd `dataType` reads/writes the wrong width with `Good` status):

| Driver | File:line (helper) | Enum fields read | `Writable` handling |
|---|---|---|---|
| Modbus | `Driver.Modbus.Contracts/ModbusEquipmentTagParser.cs:65-67` | `region`, `dataType`, `byteOrder` (:32-34) | hard-coded `Writable: true` (:57) |
| S7 | `Driver.S7.Contracts/S7EquipmentTagParser.cs:59-61` | `dataType` (:34) | hard-coded `true` (:49, "node-level authz governs writes") |
| AbCip | `Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs:86-88` | `dataType` (:45) | **honours optional `"writable"` key, default true (:52-57) — the model** |
| AbLegacy | `Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs:60-62` | `dataType` (:41) | hard-coded `true` (:52) |
| TwinCAT | `Driver.TwinCAT.Contracts/TwinCATEquipmentTagParser.cs:47-49` | `dataType` (:31) | hard-coded `true` (:39) |
| FOCAS | `Driver.FOCAS.Contracts/FocasEquipmentTagParser.cs:43-45` | `dataType` (:31) | hard-coded `true` (:35) — **yet the only real backend returns `BadNotWritable` for every address** (05/UNDER-1, `Wire/WireFocasClient.cs:71-73`) |

Adjacent confirmed defects folded into scope (05 report):
- **FOCAS capability-gate bypass:** authored tags run `FocasCapabilityMatrix.Validate` at init (`FocasDriver.cs:115-119`); equipment tags resolved via `_parseRef` (`FocasDriver.cs:69`) reach `ResolveParsedAddress` (:243-247) with **no matrix check** — only the address-parse throw.
- **Modbus probe parity break:** `ModbusDriverProbe` binds the *runtime* `ModbusDriverOptions` (TimeSpan fields), not the factory DTO shape (`ModbusDriverProbe.cs:19-24, 36`) — a DB config with `timeoutMs` silently loses its timeout in the probe. OpcUaClient documents probe/factory parse-parity as the rule (`OpcUaClientDriverProbe.cs:22`).

### Structural facts that shape the design (all verified in-tree)

- **`Commons` is a near-leaf** (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/`): zero `ProjectReference`s, packages only `Akka` + `ZB.MOM.WW.Audit`. Houses the precedent `Types/EquipmentScriptPaths.cs` — created exactly to de-duplicate a composer/artifact cross-seam parser. Referenced today by `OpcUaServer`, `Runtime`, `ControlPlane` — **but NOT by `Configuration`** (which references only `Core.Abstractions`). Adding `Configuration → Commons` is cycle-safe (Commons references nothing in-repo).
- **`Core.Abstractions` is a pure leaf** (zero `ProjectReference`s, zero `PackageReference`s). Houses `EquipmentTagRefResolver<TDef>` — the shared seam all six drivers construct (`ModbusDriver.cs:106`, `S7Driver.cs:53`, `TwinCATDriver.cs:66`, `AbLegacyDriver.cs:62`, `AbCipDriver.cs:130`, `FocasDriver.cs:69`).
- **The driver `.Contracts` projects are deliberately dependency-free**: `S7.Contracts` and `FOCAS.Contracts` carry an explicit `<!-- NO PackageReference. NO ProjectReference. -->` banner; AbCip/AbLegacy/TwinCAT Contracts have no references; `Modbus.Contracts` references only `Modbus.Addressing`. **AdminUI references Contracts projects directly** (the typed tag editors reuse driver enums) — this is why they must stay dependency-light. A `Core.Abstractions` reference adds a zero-dependency leaf assembly, so the banner's *intent* (no dependency-closure growth for lightweight consumers) is preserved.
- **Deploy gate:** `AdminOperationsActor` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs:176`) runs `DraftValidator.Validate(draft)` and rejects on **any** `ValidationError` (record `Code`/`Message`/`Context` — **no severity field, no warnings channel exists today**). Result message: `StartDeploymentResult` (`Commons/Messages/Admin/StartDeploymentResult.cs`) carries a free-text `Message`. `ControlPlane` does **not** reference any driver Contracts project (csproj :22-28).
- **AdminUI-side validation:** `AdminUI/Uns/TagEditors/TagConfigValidator.cs` dispatches by `DriverType` (canonical strings `"Modbus"`, `"S7"`, `"AbCip"`, `"AbLegacy"`, `"TwinCat"`, `"Focas"`, `"OpcUaClient"`, case-insensitive) to typed `<Driver>TagConfigModel.Validate()`; models are string-enum (`JsonStringEnumConverter` — the driver-enum-serialization history) and **preserve unknown keys**.
- **Existing test assets to build on:** `OpcUaServer.Tests/ExtractTagAlarmTests.cs`, `ExtractTagHistorizeTests.cs`, `ExtractTagArrayTests.cs` (direct tests of the composer internals — `InternalsVisibleTo` at OpcUaServer csproj :25); `Runtime.Tests/Drivers/DeploymentArtifact{Historize,Array,Alias,DeviceHost}ParityTests.cs` (compose→encode→decode parity pattern); `Commons.Tests/EquipmentScriptPathsTests.cs`; `Core.Abstractions.Tests/EquipmentTagRefResolverTests.cs`.

---

# Part 1 — 01/C-1 + 01/P-1: `TagConfigIntent.Parse` in Commons

## Restatement

The platform's cross-driver TagConfig *intent* keys (`FullName`, `alarm`, `isHistorized`/`historianTagname`, `isArray`/`arrayLength`, plus the DeviceConfig `HostAddress`) are parsed by four hand-synced copies across four projects, held equal only by "MUST parse identically (byte-parity)" comments. The equality of the live-compose and artifact-decode address spaces — and therefore diff/no-op-redeploy correctness — rests on humans keeping four JSON parsers in sync (C-1). Separately, the composer parses the *same* blob four times per tag: 40k parses for a 10k-tag fleet per compose, doubled on the artifact-decode side (P-1).

## Verification

Confirmed as enumerated above. The four call sites in the composer's equipment-tag `Select` (:417-433) each trigger an independent `JsonDocument.Parse` (:541, :606, :638, :670). Site semantics are *almost* identical with two deliberate variations the shared type must carry:

1. **FullName fallback:** composer/artifact/walker fall back to the **raw blob** when `FullName` is absent/non-string/non-object/malformed (an equipment tag's TagConfig JSON *is* its wire reference for the six protocol drivers — the resolver's `_parseRef` path depends on this). `DraftValidator` returns **`null`** on absent — it wants the *explicit* FullName only (Galaxy rule).
2. **Null input:** artifact tolerates `null` (`tagConfig ?? string.Empty`); composer/walker take non-null (DB `CK_Tag_TagConfig_IsJson` guarantees it). `Parse(string?)` returning `FullName = tagConfig ?? string.Empty` preserves both.

Semantics to port verbatim (the byte-parity contract):
- `alarm`: object-typed `alarm` property only; `alarmType` string default `"AlarmCondition"`; `severity` int32 number default `500`; `historizeToAveva` **bool?** — `null` unless explicitly `true`/`false` (absent/non-bool ⇒ null ⇒ default-on at the HistorianAdapterActor gate).
- `isHistorized`: `true` only when a bool `true`; `historianTagname` non-whitespace string else `null`, **not trimmed**.
- `isArray`: bool; `arrayLength` honoured **only when `isArray` is true** AND a JSON number fitting `uint` (else `null` ⇒ unbounded `ArrayDimensions=[0]`).
- Malformed JSON / non-object root / blank ⇒ all intents default (FullName = raw blob), **never throws**.

## Root cause

No shared home existed on the dependency graph: OpcUaServer doesn't reference the Core driver assembly, Configuration doesn't reference Commons — so each seam grew its own copy, synced by comments (OVERALL cross-cutting theme #2, "fixes don't flow back to shared templates"). The codebase already demonstrated the fix once: `EquipmentScriptPaths` was created in Commons to de-duplicate exactly this composer/artifact seam for script dependency-ref extraction.

## Proposed design

One record + one parse in **`Commons/Types/`** (the `EquipmentScriptPaths` precedent), consumed by all four seams. Parsing once per tag resolves P-1 in the same change.

```csharp
// src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;

/// <summary>The cross-driver platform intents parsed from a Tag's schemaless TagConfig JSON.
/// SINGLE SOURCE OF TRUTH for the byte-parity contract shared by the live-compose seam
/// (AddressSpaceComposer), the artifact-decode seam (DeploymentArtifact), the draft gate
/// (DraftValidator), and the walker. One JsonDocument.Parse per blob. Never throws.</summary>
public sealed record TagConfigIntent(
    string FullName,            // top-level "FullName" string, else the raw blob (wire-reference fallback)
    string? ExplicitFullName,   // the "FullName" property when present-and-string, else null (DraftValidator semantics)
    TagAlarmIntent? Alarm,
    bool IsHistorized,
    string? HistorianTagname,
    bool IsArray,
    uint? ArrayLength)
{
    public static TagConfigIntent Parse(string? tagConfig);
}

/// <summary>The optional native-alarm intent ("alarm" object).</summary>
public sealed record TagAlarmIntent(string AlarmType, int Severity, bool? HistorizeToAveva);

// src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs
public static class DeviceConfigIntent
{
    public static string? TryExtractHost(string? deviceConfigJson);  // parse + normalize; null on absent/blank/unparseable
    public static string NormalizeHost(string host);                 // trim + ToLowerInvariant — the single normalization authority
}
```

Seam retrofit:
- **Composer** (`AddressSpaceComposer.cs`): the equipment-tag `Select` computes `var intent = TagConfigIntent.Parse(t.TagConfig);` **once** and projects all fields off it; `Alarm` maps `TagAlarmIntent → EquipmentTagAlarmInfo` (one-line ctor call — `EquipmentTagAlarmInfo` stays in OpcUaServer because the plan records and their consumers live there). The four private/internal `Extract*` statics are deleted. `TryExtractDeviceHost`/`NormalizeDeviceHost` delegate to (then are replaced by) `DeviceConfigIntent`.
- **DeploymentArtifact** (`Runtime`): the four private mirrors are deleted; the tag-decode loop calls `TagConfigIntent.Parse` once per tag element; the `:837` device-host call moves to `DeviceConfigIntent.TryExtractHost`. `DriverHostActor` (:742, :776) moves to `DeviceConfigIntent.NormalizeHost`.
- **DraftValidator** (`Configuration`): gains a `ProjectReference` to Commons (cycle-safe — verified: Commons has zero in-repo references); `ExtractTagConfigFullName(...)` → `TagConfigIntent.Parse(t.TagConfig).ExplicitFullName` (null-on-absent preserved by construction — this is why the record carries both `FullName` and `ExplicitFullName`).
- **EquipmentNodeWalker** (`Core`): `ExtractFullName` body becomes a one-line delegate to `TagConfigIntent.Parse(...).FullName`. (Full walker deletion is round-1 U-1's job, not this plan's — but the swap removes the fourth parser copy regardless of when U-1 lands, and the tree-wide doc comments citing the walker as "the canonical parser" — `DeploymentArtifact.cs:666`, `OpcUaClientTagConfigModel.cs:11`, `IScriptTagCatalog.cs`, `OtOpcUaNodeManager.cs` — are repointed to `TagConfigIntent`.)

**Alternatives considered and rejected:**
- *House the record in `Core.Abstractions`* — rejected: `Configuration` already references it, but `Commons` is the established home for exactly this kind of cross-seam pure parser (`EquipmentScriptPaths`), and OpcUaServer/Runtime/ControlPlane already reference Commons. Splitting "script-path intents in Commons, tag-config intents in Core.Abstractions" would scatter the shared-contract home the reports say is missing.
- *Move `EquipmentTagAlarmInfo` to Commons instead of mapping* — rejected: it participates in OpcUaServer's public plan-record surface and its (de)serialization; the two one-line mappings are covered by the parity corpus and avoid public-API churn.
- *Keep four parsers + add a golden-file parity test* — rejected (round-1 concurs): tests the symptom; still four code paths to drift.
- *Fold `TryReadSpec`'s ResilienceConfig read in* — rejected: trivial string passthrough, no intent semantics; consolidation would couple the artifact spec format to Commons for zero dedup value.

## Implementation steps (byte-parity test FIRST, then swap one site at a time)

1. **Characterization net first:** add a golden TagConfig corpus (~30 blobs: happy path, blank/null, non-object root, malformed JSON, `FullName` absent/non-string, alarm object with each field absent/wrong-type, `historizeToAveva` true/false/non-bool, `historianTagname` whitespace, `isArray`/`arrayLength` combinations incl. negative/overflow/non-number/length-without-flag, unknown keys, platform+driver keys mixed) and a compose→encode→decode parity test in `Runtime.Tests` (the existing `DeploymentArtifact*ParityTests` pattern) asserting the composer plan equals the decoded plan field-by-field over the whole corpus. **This must pass on the unmodified tree** — it proves sites 1+2 parse identically TODAY and stays green through every subsequent swap. Add corpus-driven FullName characterization for the walker (`Core.Tests`) and the DraftValidator Galaxy rule (`Configuration.Tests`).
2. Add `Commons/Types/TagConfigIntent.cs` + `DeviceConfigIntent.cs`, porting semantics **verbatim** from the composer (the canonical copy). Port the three `OpcUaServer.Tests/ExtractTag*Tests` tables into `Commons.Tests/TagConfigIntentTests` — this becomes the single byte-parity authority.
3. Swap the composer (parse-once), keeping the parity corpus green. 4. Swap DeploymentArtifact. 5. Swap DraftValidator (+ `Configuration → Commons` reference). 6. Swap the walker + repoint doc comments. 7. Re-home device-host (composer statics deleted; `DriverHostActor`/`DeploymentArtifact` callers updated). 8. Retire the now-redundant `OpcUaServer.Tests/ExtractTag*Tests` (tables live in Commons.Tests; the compose-level behavior is held by the parity corpus). 9. Final sweep: `grep -rn "MUST parse identically" src/` returns zero hits.

## Tests

- `Commons.Tests/TagConfigIntentTests` — exhaustive table (ported + extended): every field × present/absent/wrong-type/malformed; `ExplicitFullName` vs `FullName` fallback distinction; never-throws property over the corpus.
- `Commons.Tests/DeviceConfigIntentTests` — ported from composer semantics + `DeploymentArtifactDeviceHostParityTests` expectations (normalization idempotence, null-on-blank).
- `Runtime.Tests/Drivers/TagConfigCorpusParityTests` — the cross-seam characterization (step 1); permanent regression guard.
- Existing `DeploymentArtifact{Historize,Array,Alias,DeviceHost}ParityTests` must stay green untouched throughout.

## Effort + Risk

**Effort: M** (mostly mechanical after the corpus lands). **Risk: Medium** — touches the byte-parity crown-jewel invariant. Mitigations: characterization corpus lands *before* any code moves; semantics ported verbatim (no behavior change intended anywhere in Part 1); one seam swapped per commit with the corpus green after each; `TreatWarningsAsErrors` + the compiler prove no stale callers.

---

# Part 2 — 05/CONV-2 (+UNDER-1/UNDER-6 seams): shared `ReadEnum`, Writable defaults, two-mode strictness rollout

## Restatement

The equipment-tag TagConfig path — **the product's primary authoring surface since Galaxy-standard-driver** — is the *most permissive* of the three parse tiers: six copy-pasted `ReadEnum` helpers silently default a typo'd enum (wrong-width reads/writes with `Good` status); four parsers hard-code `Writable: true` (FOCAS advertises writes its only backend always rejects); FOCAS equipment tags bypass the capability matrix authored tags get; the Modbus probe parses a different shape than the factory.

## Verification

Confirmed as tabulated in the Verification summary. Key structural check performed beyond the reports: **the round-1 B2 design ("hoist into Core.Abstractions beside `EquipmentTagRefResolver`") collides with the Contracts projects' explicit `NO ProjectReference` banner** (`S7.Contracts`/`FOCAS.Contracts` csproj). Resolution below.

## Root cause

Duplication-by-convention (theme #2) — the strict fail-loud `ParseEnum` exists in every factory, but the fix never flowed to the equipment-tag tier; and the equipment-tag parsers were written when authored tags were primary, so nobody revisited their defaults when TagConfig became the product.

## Proposed design

### 1. Shared helper home: `Core.Abstractions/TagConfigJson.cs` (banner amended)

```csharp
// src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;

public enum JsonEnumRead { Absent, Valid, Invalid }

/// <summary>Shared TagConfig JSON field readers for the six equipment-tag parsers.
/// Replaces the six byte-identical private ReadEnum copies (05/CONV-2).</summary>
public static class TagConfigJson
{
    /// <summary>Distinguishes absent / valid / present-but-invalid so callers can be
    /// lenient (fall back) while still *reporting* the invalid case.</summary>
    public static JsonEnumRead TryReadEnum<TEnum>(JsonElement o, string name, out TEnum value)
        where TEnum : struct, Enum;

    /// <summary>Lenient shim with today's exact semantics (absent OR invalid ⇒ fallback).</summary>
    public static TEnum ReadEnumOrDefault<TEnum>(JsonElement o, string name, TEnum fallback)
        where TEnum : struct, Enum;

    /// <summary>AbCip semantics: explicit JSON false ⇒ false; anything else ⇒ defaultValue.</summary>
    public static bool ReadWritable(JsonElement o, bool defaultValue = true);

    /// <summary>Human-readable warning for a present-but-invalid enum ("value 'X' for 'dataType'
    /// is not a valid ModbusDataType; valid: …"), or null when absent/valid.</summary>
    public static string? DescribeInvalidEnum<TEnum>(JsonElement o, string name)
        where TEnum : struct, Enum;
}
```

Each Contracts project gains `<ProjectReference>` to `Core.Abstractions` and its banner comment is amended to: `<!-- NO PackageReference. ProjectReference only to the zero-dependency Core.Abstractions leaf. -->`. **Why this is safe:** `Core.Abstractions` has zero `PackageReference`s and zero `ProjectReference`s (verified), so AdminUI/Cli consumers of the Contracts projects gain exactly one dependency-free assembly — the banner's intent (no closure growth) is preserved, and the helper lands beside `EquipmentTagRefResolver`, the seam theme #6 designated as the drivers' shared home.

**Alternatives considered and rejected:**
- *Linked shared-source file (`<Compile Include>` across six csprojs)* — rejected: zero precedent in this repo (verified: no `Compile Include` links anywhere in `src/`); invisible coupling that IDEs and reviewers routinely miss.
- *New leaf project `Driver.Contracts.Shared`* — rejected: project sprawl for ~60 lines when a suitable zero-dep leaf already exists and is already the resolver's home.
- *Keep six copies, make each strict, add a cross-driver conformance test* — rejected: test-enforced parity is the exact anti-pattern C-1 exists to kill.

### 2. Two-mode strictness rollout — **the compatibility decision (documented)**

**Constraint:** parse-strictness changes are behavior-affecting for EXISTING deployed configs. A typo'd enum that silently defaulted yesterday must not brick a deploy (or a running server) today.

**Decision — three phases, this plan ships the first two:**

| Phase | What | Behavior for existing configs |
|---|---|---|
| **A (this plan, default)** | Runtime parsers stay **lenient** (`ReadEnumOrDefault` — bit-for-bit today's semantics). Each parser additionally exposes `Inspect(reference)` returning warnings (invalid enum values via `DescribeInvalidEnum`; structurally-unparseable TagConfig, which today becomes a silent runtime `BadNodeIdUnknown`). The deploy gate surfaces these as **non-blocking warnings**: logged + appended to the `StartDeploymentResult.Message` (existing free-text field — no message-contract change). | **Unchanged.** Running servers untouched; re-deploys succeed with visible warnings. |
| **B (this plan, opt-in)** | New option `Deployment:TagConfigValidationMode` = `Warn` (default) \| `Error`. In `Error` mode the same inspection results fold into the existing `ValidationError` reject list at the draft gate — a config with a typo'd enum **cannot be re-deployed** until fixed. | Running servers still untouched (the gate only sees re-deploys — "existing runtime behavior unchanged unless config is re-deployed through the gate"). Operators flip to `Error` per environment once their fleet deploys warning-clean. |
| **C (future, out of scope, recorded)** | Flip the runtime parsers to strict (`TryReadEnum == Invalid` ⇒ `TryParse` returns false ⇒ resolver miss ⇒ `BadNodeIdUnknown` instead of wrong-width `Good`) once the fleet has run `Error`-mode clean. Tracked as a follow-up in STATUS.md when this plan executes. | Behavior change, deliberately deferred: the report's "correct outcome" lands only after the gate has proven no live config depends on the silent default. |

**Where the deploy-time inspection runs:** a new `EquipmentTagConfigInspector` static dispatch map in **`ControlPlane`** (mirroring AdminUI's `TagConfigValidator` shape: `DriverType → Func<string, IReadOnlyList<string>>`, case-insensitive, canonical strings `"Modbus"/"S7"/"AbCip"/"AbLegacy"/"TwinCat"/"Focas"`). ControlPlane gains references to the six Contracts projects — the same dependency-light consumption AdminUI already does; unmapped drivers (Galaxy, OpcUaClient) are skipped exactly as in the AdminUI validator. Wired into `AdminOperationsActor` immediately after the `DraftValidator.Validate` gate (:176), over equipment tags only (`EquipmentId != null`). *Alternative rejected:* teaching `DraftValidator` (Configuration) the driver vocab — would couple Configuration to every driver Contracts project and grow per driver; the DriverType-dispatch precedent already lives one layer up.

**Why not surface strictness through the composer:** the composer is pure and deploy-critical ("same inputs → same outputs", 01/C-4) — a warnings channel there would bolt reporting onto the byte-parity seam. The draft gate is the designed authority for config validity (its own doc comment says so) and already has the reject/summary plumbing.

### 3. Unknown-key handling — **decision: always preserve, never warn**

The TagConfig blob is a **shared namespace**: platform intent keys (`FullName`, `alarm`, `isHistorized`, `historianTagname`, `isArray`, `arrayLength`) coexist with per-driver address keys (`region`, `address`, `dataType`, …) and any forward-compat keys the typed editors deliberately preserve (`FromJson`/`ToJson` preserve-unknown-keys contract). There is no single canonical schema to validate against, so unknown-key strictness would false-positive on every platform key from a driver parser's perspective and vice versa. Parsers continue to read known fields and ignore the rest; `Inspect` never warns on unknown keys. Documented as a deliberate non-goal in the helper's XML docs.

### 4. `Writable` default per driver capability

| Driver | Change | Compat |
|---|---|---|
| AbCip | none (already honours `writable`, default true) — helper swap only | zero |
| Modbus, S7, AbLegacy, TwinCAT | honour optional `writable` key via `TagConfigJson.ReadWritable(root)` (default **true**) | zero for existing configs (absent ⇒ true, today's value); makes read-only equipment tags authorable (UNDER-6) |
| FOCAS | **force `Writable: false`** (ignore any `writable:true`) until PMC writes exist in the wire client; `Inspect` warns when a config requests `writable:true` | behavior-affecting **correction**: a write that today reaches the backend and always fails with `BadNotWritable` (`WireFocasClient.cs:71-73`) now fails at the driver seam with the same family of Bad status — no successful operation changes |

**FOCAS capability gate:** wrap the `_parseRef` lambda in `FocasDriver` (:69) so a parser-produced def immediately runs `FocasAddress.TryParse` + `FocasCapabilityMatrix.Validate(device.Options.Series, parsed)` (device looked up by `def.DeviceHostAddress`); on rejection return `null` ⇒ resolver miss ⇒ `BadNodeIdUnknown` — equipment tags get the same pre-flight authored tags get (:115-119). Behavior-affecting correction: a capability-violating tag that today fails at read-time with a wire error now fails at resolve-time with a precise status.

### 5. Modbus probe parity

`ModbusDriverProbe` parses the factory DTO shape (or a shared `TryParseConfig` used by both), so `timeoutMs` binds identically to the factory (`ModbusDriverProbe.cs:19-24, 36`; the OpcUaClient parity rule, `OpcUaClientDriverProbe.cs:22`). Included because it is the third strictness tier of the same CONV-2 finding; small and independent.

### 6. AdminUI sync

- `TagConfigValidator` / typed editors need **no strictness change**: enum fields are dropdown-bound (string-typed via `JsonStringEnumConverter` — the driver-enum-serialization lesson), so typos cannot be authored through mapped editors; the deploy gate covers SQL-seeded/API-authored configs and the raw-JSON textarea (unmapped drivers).
- Add a `writable` checkbox to the six typed editor models/editors (default checked; FOCAS's editor shows it disabled-unchecked with a "FOCAS writes unsupported" hint) so the newly-honoured key is authorable without hand-editing JSON. Unknown-key preservation means hand-authored `writable` already round-trips today. **Live-verify caveat:** AdminUI has no bUnit — the checkbox task requires a docker-dev `/run` verification pass when executed (out of this plan's static scope; flagged in the task).

## Implementation steps

1. `TagConfigJson` + tests in `Core.Abstractions` (helper is additive; nothing consumes it yet).
2. Per driver (six parallel, identical shape): add the Contracts→Core.Abstractions reference (+ banner amendment where present); **behavior-freeze test first** (typo'd enum still silently defaults — pins Phase-A lenient semantics); replace the private `ReadEnum` with `TagConfigJson.ReadEnumOrDefault`; add `Inspect(reference)`; honour/force `writable` per the table; delete the private copy.
3. FOCAS driver-side capability-gate wrap (+ test: capability-violating equipment ref fails resolve).
4. `EquipmentTagConfigInspector` in ControlPlane + `AdminOperationsActor` wiring + `TagConfigValidationMode` option. **Consuming test through the actor**, not just the map (the F10b/PR#423 lesson: unit-green ≠ wired).
5. Modbus probe DTO-shape fix + `timeoutMs` round-trip test.
6. AdminUI `writable` checkboxes (mechanical; live-verify flagged).
7. Docs: `docs/Uns.md` TagConfig section (writable key, validation modes), migration note for the FOCAS writable flip, the phase-C follow-up recorded in `archreview/plans/STATUS.md`.

## Tests

- `Core.Abstractions.Tests/TagConfigJsonTests` — `TryReadEnum` Absent/Valid/Invalid matrix (case-insensitive match preserved), `ReadWritable` explicit-false-only semantics, `DescribeInvalidEnum` lists valid names.
- **Per-driver strictness suite** (each driver's existing `.Tests` project): (a) behavior-freeze — typo'd enum defaults exactly as today (Phase A pin); (b) `Inspect` reports the typo with field name + valid values; (c) `writable:false` honoured (AbCip regression; new for Modbus/S7/AbLegacy/TwinCAT); (d) FOCAS: `Writable` always false + `writable:true` warning; (e) absent enum ⇒ documented default (unchanged).
- **Draft-gate warning tests** (ControlPlane/Admin tests — note: `*.Admin.Tests` are DB-backed per CLAUDE.md, so prefer a unit seam on the inspector + one actor-level test with an in-memory draft): Warn mode — deploy accepted, warnings in log + result message; Error mode — deploy rejected with the same texts folded into the error summary; clean config — zero warnings, no message noise; unmapped driver (Galaxy) — skipped.
- Modbus probe: config with `timeoutMs` round-trips the timeout value.

## Effort + Risk

**Effort: M** (six mechanical parser swaps + one gate wiring). **Risk: Low-Medium.** Phase A is behavior-frozen by construction (freeze tests land before each swap); the only behavior-affecting edges are the two documented FOCAS corrections (write path already always failed; gate moves failure earlier) and the opt-in `Error` mode (off by default). The wiring risk (inspector built but never called) is covered by the actor-level consuming test.

---

# Overall effort + sequencing

**Total: M — roughly 2–2.5 focused dev-days across 24 bite-sized tasks.** Part 1 and Part 2 are independent until T24 and can proceed in parallel branches (`refactor/r2-11-tagconfig-intent`, `feat/r2-11-parse-strictness`); Part 1's seam swaps are strictly sequential behind the corpus; Part 2's six driver tasks are mutually parallel behind the helper. Worktree-isolate parallel implementers (the shared-tree git-index race noted in project memory).

---

# Task breakdown

> TDD discipline: tasks marked *characterization* must be **green on the unmodified tree** before their companion refactor starts (they pin today's behavior); all other test steps follow FAIL→PASS. All paths absolute from repo root `/Users/dohertj2/Desktop/OtOpcUa/`.

### T1 — Golden corpus + compose→encode→decode parity characterization test

**Classification:** test-only (characterization — must pass on unmodified tree)
**Estimated implement time:** 5 min
**Parallelizable with:** T3, T4, T11
**Files:** `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigGoldenCorpus.cs` (new), `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/TagConfigCorpusParityTests.cs` (new)
**Steps:** (1) Author the ~30-blob corpus (see Part 1 step 1 for the case list) as a shared static. (2) Following the `DeploymentArtifactHistorizeParityTests` pattern, compose a fleet with one tag per corpus blob, encode→decode, assert `EquipmentTagPlan` equality field-by-field (FullName, Alarm, IsHistorized, HistorianTagname, IsArray, ArrayLength). (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~TagConfigCorpusParityTests"` → **PASS on unmodified tree** (this is the proof the 4 sites parse identically today).
**Commit:** `test(runtime): golden TagConfig corpus + compose/decode parity characterization (R2-11)`

### T2 — Corpus characterization for walker + DraftValidator FullName semantics

**Classification:** test-only (characterization)
**Estimated implement time:** 5 min
**Parallelizable with:** T1, T3, T4
**Files:** `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/OpcUa/EquipmentNodeWalkerFullNameCorpusTests.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorGalaxyFullNameCorpusTests.cs` (new)
**Steps:** (1) Walker: table-test `EquipmentNodeWalker.ExtractFullName` over the corpus expecting the raw-blob-fallback column. (2) DraftValidator: Galaxy-driver drafts per corpus blob asserting `GalaxyTagMissingReference` fires exactly for the null-`ExplicitFullName` rows. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~EquipmentNodeWalkerFullNameCorpusTests"` and `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests --filter "FullyQualifiedName~DraftValidatorGalaxyFullNameCorpusTests"` → PASS on unmodified tree.
**Commit:** `test(core,config): FullName-semantics characterization for walker + draft gate (R2-11)`

### T3 — `TagConfigIntent` + `TagAlarmIntent` in Commons (TDD)

**Classification:** structural (no behavior change; new type)
**Estimated implement time:** 10 min
**Parallelizable with:** T1, T2, T4, T11
**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/TagConfigIntent.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/TagConfigIntentTests.cs` (new)
**Steps:** (1) Write `TagConfigIntentTests` first — port the three `OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests` tables + FullName/ExplicitFullName cases + never-throws over the corpus → FAIL (type absent). (2) Implement `TagConfigIntent.Parse` porting composer semantics **verbatim** (single `JsonDocument.Parse`; raw-blob FullName fallback; `Parse(null).FullName == ""`; alarm defaults `"AlarmCondition"`/500/null; untrimmed `historianTagname`; `arrayLength` only when `isArray`). (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~TagConfigIntentTests"` → PASS.
**Commit:** `feat(commons): TagConfigIntent.Parse — single home for the byte-parity TagConfig intents (R2-11, 01/C-1)`

### T4 — `DeviceConfigIntent` in Commons (TDD)

**Classification:** structural (no behavior change; new type)
**Estimated implement time:** 5 min
**Parallelizable with:** T1–T3, T11
**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/DeviceConfigIntent.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeviceConfigIntentTests.cs` (new)
**Steps:** (1) Tests first (port composer `TryExtractDeviceHost`/`NormalizeDeviceHost` semantics: null on blank/non-object/non-string/whitespace; trim+lower normalization; idempotence) → FAIL. (2) Implement. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests --filter "FullyQualifiedName~DeviceConfigIntentTests"` → PASS.
**Commit:** `feat(commons): DeviceConfigIntent — device-host extraction + normalization home (R2-11)`

### T5 — Composer swap: parse-once via `TagConfigIntent` (P-1 fix)

**Classification:** structural (no behavior change — parity-guarded)
**Estimated implement time:** 10 min
**Parallelizable with:** — (first seam swap; blocks T6, T7, T10)
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`
**Steps:** (1) In the equipment-tag `Select` (:417-433): `var intent = TagConfigIntent.Parse(t.TagConfig);`, project all six fields off `intent`, map `intent.Alarm` → `new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)`. (2) Delete `ExtractTagFullName`/`ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray` (:536-551, :601-686). (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~TagConfigCorpusParityTests"` → still PASS; `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` → the three ExtractTag*Tests fail to compile — mark them for T10 (or convert in the same commit if trivial; keep the commit green by deleting their direct-static references now and relying on Commons.Tests, per T10).
**Commit:** `refactor(opcuaserver): composer parses TagConfig once per tag via TagConfigIntent (R2-11, 01/P-1)`

### T6 — Device-host re-home: composer statics → `DeviceConfigIntent`

**Classification:** structural (no behavior change)
**Estimated implement time:** 5 min
**Parallelizable with:** T7 (different files after T5)
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (:715, :742, :776), `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (:822-837)
**Steps:** (1) Update the three cross-project callers to `DeviceConfigIntent.TryExtractHost`/`NormalizeHost`; update the :715 doc comment. (2) Delete `AddressSpaceComposer.TryExtractDeviceHost`/`NormalizeDeviceHost`. (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~DeploymentArtifactDeviceHostParityTests"` → PASS.
**Commit:** `refactor(runtime,opcuaserver): device-host normalization re-homed to Commons DeviceConfigIntent (R2-11)`

### T7 — DeploymentArtifact swap

**Classification:** structural (no behavior change — parity-guarded)
**Estimated implement time:** 5 min
**Parallelizable with:** T6, T8, T9
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`
**Steps:** (1) In the tag-decode loop, call `TagConfigIntent.Parse` once per tag element; delete the four private mirrors (~:668-780). (2) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~TagConfigCorpusParityTests|FullyQualifiedName~DeploymentArtifactHistorizeParityTests|FullyQualifiedName~DeploymentArtifactArrayParityTests|FullyQualifiedName~DeploymentArtifactAliasParityTests"` → all PASS.
**Commit:** `refactor(runtime): DeploymentArtifact decodes TagConfig via shared TagConfigIntent (R2-11, 01/C-1)`

### T8 — DraftValidator swap (+ Configuration → Commons reference)

**Classification:** structural (no behavior change — T2 characterization guards)
**Estimated implement time:** 5 min
**Parallelizable with:** T6, T7, T9
**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj`, `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs`
**Steps:** (1) Add `<ProjectReference>` to Commons. (2) Replace `ExtractTagConfigFullName(t.TagConfig)` with `TagConfigIntent.Parse(t.TagConfig).ExplicitFullName`; delete the private copy (:57-71). (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests --filter "FullyQualifiedName~DraftValidatorGalaxyFullNameCorpusTests"` → PASS (null-on-absent preserved).
**Commit:** `refactor(config): DraftValidator Galaxy rule reads ExplicitFullName via TagConfigIntent (R2-11)`

### T9 — Walker swap + tree-wide doc-comment repointing

**Classification:** structural (no behavior change; dormant path)
**Estimated implement time:** 5 min
**Parallelizable with:** T6, T7, T8
**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` (:193-208), plus doc-comment cites at `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs:666`-area, `OpcUaClientTagConfigModel.cs:11`, `IScriptTagCatalog.cs`, `OtOpcUaNodeManager.cs` (grep `EquipmentNodeWalker.ExtractFullName` for the authoritative list)
**Steps:** (1) `ExtractFullName` body → `TagConfigIntent.Parse(tagConfig).FullName` (Core already references Configuration; add Core → Commons `ProjectReference` if absent — verify). (2) Repoint every "canonical parser" doc cite to `TagConfigIntent.Parse`. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~EquipmentNodeWalkerFullNameCorpusTests"` → PASS. Note in the commit that full walker deletion remains round-1 U-1's task.
**Commit:** `refactor(core): EquipmentNodeWalker.ExtractFullName delegates to TagConfigIntent; repoint canonical-parser doc cites (R2-11)`

### T10 — Retire redundant `OpcUaServer.Tests/ExtractTag*Tests`

**Classification:** test-only (cleanup; authority moved)
**Estimated implement time:** 3 min
**Parallelizable with:** T11
**Files:** delete `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ExtractTagAlarmTests.cs`, `ExtractTagHistorizeTests.cs`, `ExtractTagArrayTests.cs`
**Steps:** (1) Confirm every table row exists in `Commons.Tests/TagConfigIntentTests` (T3 ported them). (2) Delete; `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` → PASS. (3) `grep -rn "MUST parse identically" src/` → **zero hits** (the comment-enforced contract is gone).
**Commit:** `test(opcuaserver): retire ExtractTag* tests — byte-parity authority is Commons TagConfigIntentTests (R2-11)`

### T11 — `TagConfigJson` strict-capable helpers in Core.Abstractions (TDD)

**Classification:** structural (additive; nothing consumes yet)
**Estimated implement time:** 10 min
**Parallelizable with:** T1–T4, T10
**Files:** `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/TagConfigJson.cs` (new), `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/TagConfigJsonTests.cs` (new)
**Steps:** (1) Tests first: `TryReadEnum` Absent/Valid/Invalid (case-insensitive, matching the six copies' `ignoreCase: true`); `ReadEnumOrDefault` ≡ today's lenient semantics; `ReadWritable` explicit-false-only; `DescribeInvalidEnum` names field, value, and valid members → FAIL. (2) Implement per the Part 2 §1 API. (3) `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests --filter "FullyQualifiedName~TagConfigJsonTests"` → PASS.
**Commit:** `feat(core-abstractions): TagConfigJson shared strict-capable field readers (R2-11, 05/CONV-2)`

### T12 — Modbus parser: helper swap + `writable` + `Inspect`

**Classification:** structural + behavior-frozen (freeze test first); `writable` is additive
**Estimated implement time:** 10 min
**Parallelizable with:** T13–T17, T19
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Contracts.csproj`, `.../ModbusEquipmentTagParser.cs`, `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusEquipmentTagParserStrictnessTests.cs` (new)
**Steps:** (1) Freeze test first: `{"region":"HoldingRegisters","address":1,"dataType":"Intt16"}` still parses with `DataType == Int16` → PASS on unmodified tree. (2) Add Core.Abstractions reference; replace private `ReadEnum` (:65-67) with `TagConfigJson.ReadEnumOrDefault` for `region`/`dataType`/`byteOrder`; `Writable: TagConfigJson.ReadWritable(root)` (:57). (3) Add `Inspect(string reference)` → warnings for each invalid enum + unparseable-TagConfig. New tests: freeze still green; `Inspect` reports the `Intt16` typo; `writable:false` honoured; absent-writable ⇒ true. (4) `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusEquipmentTagParserStrictnessTests"` FAIL→PASS.
**Commit:** `feat(driver-modbus): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`

### T13 — S7 parser: same shape as T12

**Classification:** structural + behavior-frozen; `writable` additive
**Estimated implement time:** 5 min
**Parallelizable with:** T12, T14–T17
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts.csproj` (**amend the NO-ProjectReference banner** per Part 2 §1), `.../S7EquipmentTagParser.cs` (:34, :49, :59-61), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7EquipmentTagParserStrictnessTests.cs` (new)
**Steps:** as T12 (dataType only; keep the "node-level authz" comment updated to note the honoured key). Filter: `--filter "FullyQualifiedName~S7EquipmentTagParserStrictnessTests"` FAIL→PASS.
**Commit:** `feat(driver-s7): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`

### T14 — AbCip parser: helper swap + `Inspect` (writable already honoured)

**Classification:** structural + behavior-frozen
**Estimated implement time:** 5 min
**Parallelizable with:** T12, T13, T15–T17
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/*.csproj`, `.../AbCipEquipmentTagParser.cs` (:45, :52-57, :86-88), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipEquipmentTagParserStrictnessTests.cs` (new)
**Steps:** as T12 minus the writable change (swap the hand-rolled :53-54 read to `TagConfigJson.ReadWritable` with a regression test that explicit-false semantics are bit-identical). FAIL→PASS.
**Commit:** `feat(driver-abcip): shared TagConfigJson readers + Inspect warnings (R2-11)`

### T15 — AbLegacy parser: same shape as T12

**Classification:** structural + behavior-frozen; `writable` additive
**Estimated implement time:** 5 min
**Parallelizable with:** T12–T14, T16, T17
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/*.csproj`, `.../AbLegacyEquipmentTagParser.cs` (:41, :52, :60-62), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyEquipmentTagParserStrictnessTests.cs` (new)
**Steps:** as T12. FAIL→PASS.
**Commit:** `feat(driver-ablegacy): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`

### T16 — TwinCAT parser: same shape as T12

**Classification:** structural + behavior-frozen; `writable` additive
**Estimated implement time:** 5 min
**Parallelizable with:** T12–T15, T17
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Contracts/*.csproj`, `.../TwinCATEquipmentTagParser.cs` (:31, :39, :47-49), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATEquipmentTagParserStrictnessTests.cs` (new)
**Steps:** as T12. FAIL→PASS.
**Commit:** `feat(driver-twincat): shared TagConfigJson readers, writable key, Inspect warnings (R2-11)`

### T17 — FOCAS parser: helper swap + **force `Writable:false`** + `Inspect`

**Classification:** behavior-affecting (documented correction — 05/UNDER-1)
**Estimated implement time:** 10 min
**Parallelizable with:** T12–T16
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj` (**amend banner**), `.../FocasEquipmentTagParser.cs` (:31, :35, :43-45), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagParserStrictnessTests.cs` (new)
**Steps:** (1) Tests first: `Writable == false` regardless of `writable:true` (FAIL today); `Inspect` warns on `writable:true` ("FOCAS writes unsupported — forced read-only") and on invalid `dataType`. (2) Implement: `Writable: false` hard-coded with a doc comment citing `WireFocasClient.cs:71-73` + "honour the key when PMC writes ship"; helper swap. (3) `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter "FullyQualifiedName~FocasEquipmentTagParserStrictnessTests"` FAIL→PASS. Note the migration line for the docs task (T23).
**Commit:** `fix(driver-focas): equipment tags forced read-only until PMC writes exist; shared readers + Inspect (R2-11, 05/UNDER-1)`

### T18 — FOCAS capability gate on the equipment-tag resolve path

**Classification:** behavior-affecting (documented correction — failure moves earlier + clearer)
**Estimated implement time:** 10 min
**Parallelizable with:** T19
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs` (:69 `_parseRef` lambda; reuse :115-119 gate logic), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasEquipmentTagCapabilityGateTests.cs` (new)
**Steps:** (1) Test first: an equipment-tag ref whose address violates `FocasCapabilityMatrix` for the target device's series currently resolves (FAIL expected assertion) — assert it must NOT resolve (`TryResolve` false ⇒ read surfaces `BadNodeIdUnknown`). (2) Wrap `_parseRef`: after `FocasEquipmentTagParser.TryParse`, look up the device by `def.DeviceHostAddress`, run `FocasAddress.TryParse` + `FocasCapabilityMatrix.Validate`; return null on any rejection (resolver caches the negative). (3) FAIL→PASS: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests --filter "FullyQualifiedName~FocasEquipmentTagCapabilityGateTests"`.
**Commit:** `fix(driver-focas): equipment tags run the capability-matrix pre-flight authored tags get (R2-11, 05/UNDER-6)`

### T19 — Modbus probe parses the factory DTO shape

**Classification:** behavior-affecting (probe-only; fixes silent `timeoutMs` drop)
**Estimated implement time:** 10 min
**Parallelizable with:** T12–T18
**Files:** `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverProbe.cs` (:19-24, :36), the factory DTO in `ModbusDriverFactoryExtensions.cs` (extract a shared `TryParseConfig` if needed), `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverProbeParityTests.cs` (new)
**Steps:** (1) Test first: a config with `"timeoutMs": 250` probe-parses with the 250 ms timeout (FAIL today — TimeSpan-typed runtime options drop it). (2) Rebind the probe to the factory DTO shape (OpcUaClient parity rule, `OpcUaClientDriverProbe.cs:22`). (3) `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusDriverProbeParityTests"` FAIL→PASS.
**Commit:** `fix(driver-modbus): probe parses the factory DTO shape — timeoutMs no longer silently dropped (R2-11, 05/CONV-2)`

### T20 — `EquipmentTagConfigInspector` dispatch map in ControlPlane

**Classification:** structural (additive; consumed in T21)
**Estimated implement time:** 10 min
**Parallelizable with:** T22
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj` (+6 Contracts references), `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/EquipmentTagConfigInspector.cs` (new), `tests/Server/` ControlPlane test project — mirror wherever `AdminOperationsActor` unit tests live (locate via `grep -rl AdminOperationsActor tests/`)
**Steps:** (1) Tests first: known DriverType dispatches to the right `Inspect` (typo'd Modbus blob → one warning); unmapped (`GalaxyMxGateway`, `OpcUaClient`) → empty; case-insensitive keys matching `TagConfigValidator`'s canonical strings → FAIL. (2) Implement the static map (`"Modbus"/"S7"/"AbCip"/"AbLegacy"/"TwinCat"/"Focas"` → `<X>EquipmentTagParser.Inspect`). (3) FAIL→PASS.
**Commit:** `feat(controlplane): EquipmentTagConfigInspector — DriverType-dispatched deploy-time TagConfig inspection (R2-11)`

### T21 — Deploy-gate wiring + `Deployment:TagConfigValidationMode` (Warn default | Error)

**Classification:** behavior-affecting (flag-gated; Warn default = non-blocking)
**Estimated implement time:** 15 min
**Parallelizable with:** T22
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs` (after :176), the actor's options/Props plumbing (follow the existing options pattern from Host), actor-level tests beside T20's
**Steps:** (1) Tests first (in-memory draft, not DB-backed): Warn mode — typo'd equipment tag deploys **Accepted** with the warning in the log and appended to `StartDeploymentResult.Message`; Error mode — same draft **Rejected** with the text folded into the error summary; clean draft — no warnings; Galaxy tag — skipped → FAIL. (2) Wire: after the DraftValidator gate, run the inspector over `draft.Tags.Where(t => t.EquipmentId is not null)` joined to DriverType; branch on the option. **This actor-level consuming test is mandatory** (F10b lesson: an inspector built but never called is inert). (3) FAIL→PASS. Document the option in the appsettings sample.
**Commit:** `feat(controlplane): deploy-gate TagConfig strictness — Warn default, Error opt-in at the draft gate (R2-11, 05/CONV-2)`

### T22 — AdminUI: `writable` checkbox in the six typed tag editors

**Classification:** mechanical (additive UI; requires live `/run` verify at execution — AdminUI has no bUnit)
**Estimated implement time:** 15 min
**Parallelizable with:** T20, T21
**Files:** `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/{Modbus,S7,AbCip,AbLegacy,TwinCAT,Focas}TagConfigModel.cs` + matching `Components/Shared/Uns/TagEditors/*.razor`
**Steps:** (1) Model unit tests first: `writable` round-trips `FromJson`/`ToJson` (camelCase, unknown-keys preserved, absent ⇒ null/omitted so existing configs are untouched) → FAIL. (2) Add the property + checkbox (default checked; FOCAS disabled-unchecked with hint). (3) `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter "FullyQualifiedName~TagConfigModel"` (locate the exact model-test project via existing `ModbusTagConfigModel` tests) FAIL→PASS. (4) Flag: docker-dev `/run` verification of one editor (Modbus) required before merge — rebuild BOTH central-1/2.
**Commit:** `feat(adminui): writable checkbox in driver-typed tag editors (R2-11, 05/UNDER-6)`

### T23 — Docs + decision record

**Classification:** docs-only
**Estimated implement time:** 10 min
**Parallelizable with:** T24 prep
**Files:** `docs/Uns.md` (TagConfig keys: `writable`, validation modes), `docs/drivers/` FOCAS note (forced read-only migration line), `archreview/plans/STATUS.md` (R2-11 entry + the Phase-C runtime-strict follow-up), `archreview/plans/00-INDEX.md`
**Steps:** Document the two-mode rollout decision (Part 2 §2 table), the unknown-keys non-goal, the FOCAS writable flip, and the Contracts-banner amendment rationale. No test.
**Commit:** `docs: R2-11 TagConfig consolidation + strictness rollout decisions`

### T24 — Final sweep + whole-solution gate

**Classification:** verification
**Estimated implement time:** 10 min (mostly test wall-time)
**Parallelizable with:** —
**Files:** none (verification only)
**Steps:** (1) `grep -rn "MUST parse identically" src/` → 0 hits; `grep -rn "private static TEnum ReadEnum" src/` → 0 hits. (2) `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean. (3) `dotnet test ZB.MOM.WW.OtOpcUa.slnx` (integration suites need their fixtures per CLAUDE.md; unit suites must be green). (4) Confirm the parity corpus + all six freeze tests green — the byte-parity and lenient-runtime invariants held end-to-end.
**Commit:** none (or squash-fixups); merge per branch strategy above.

---

## Dependency graph (summary)

```
T1, T2, T3, T4, T11 ─ independent starters
T5  ← T1 + T3            T12–T17 ← T11 (mutually parallel)
T6  ← T4 + T5            T18 ← T17
T7  ← T5 (+T6 same file order)   T19 ← independent
T8  ← T2 + T3            T20 ← T12–T17
T9  ← T2 + T3            T21 ← T20
T10 ← T3 + T5            T22 ← independent (models only)
T23 ← T17 + T21          T24 ← everything
```

---

## Execution deviations (R2-11)

- **T5 / T10 — ExtractTag* test deletion co-located into T5.** Deleting the composer's
  `ExtractTagAlarm`/`ExtractTagHistorize`/`ExtractTagArray` statics (T5) breaks compilation of
  `OpcUaServer.Tests/ExtractTag{Alarm,Historize,Array}Tests` (they call the now-deleted internals). To
  keep T5's commit build-green — the plan's T5 step explicitly sanctions "deleting their direct-static
  references now" — the three test files were removed in the T5 commit. T10 therefore ran as the
  grep-sweep verification only (`MUST parse identically` → 0 hits in `src/`; the byte-parity authority is
  `Commons.Tests/TagConfigIntentTests`). No coverage lost: every table row was ported to
  `TagConfigIntentTests`, and the compose-level behaviour is held by the permanent
  `Runtime.Tests/TagConfigCorpusParityTests` guard.
- **T6 — composer device-host unit tests re-homed.** `AddressSpaceComposerDeviceHostTests`' two direct
  `TryExtractDeviceHost` / `NormalizeDeviceHost` static tests were removed (the statics are deleted); their
  semantics now live in `Commons.Tests/DeviceConfigIntentTests`. The suite keeps its Compose-level
  `EquipmentNode.DeviceHost` projection coverage.
- **T24 — whole-solution test sweep deferred-live.** The grep gates ran clean offline
  (`grep "MUST parse identically" src/` = 0; `grep "private static TEnum ReadEnum" src/` = 0) and
  `dotnet build ZB.MOM.WW.OtOpcUa.slnx` is **0 errors** (812 warnings are all pre-existing OTOPCUA0001 in
  untouched `*.Tests` projects, none from R2-11). Per the executor memory constraint (integration suites
  leak ~16 GB/run and other plans run concurrently), only the impacted UNIT suites were run filtered
  throughout — all green. The whole-solution `dotnet test` is left as the serial heavy pass to run before
  merge.
- **T22 — Writable checkbox live-`/run` deferred.** AdminUI has no bUnit; the 9 model round-trip tests +
  the razor compile are the offline proof. A docker-dev `/run` of one typed editor's Writable checkbox
  (rebuild both central-1/2) is the flagged live gate before merge.
