Re-ran all seven domain reviews at master f6eaa267 (reports rewritten in
place, each with a prior-finding status table): all 4 round-1 Criticals
verified closed; new top findings are the S7 connect-timeout OCE regression
(05/STAB-14), the ResilienceConfig operator-authorable brick (01/S-6), and
a batch of resilience-seam Mediums. 00-OVERALL.md carries the updated
maturity matrix + 12-item action list.
Adds R2-01..R2-12 design/implementation plans (one per action item, house
format + bite-sized TDD task breakdowns + co-located .tasks.json; 193 tasks,
~18-24 dev-days). STATUS.md updated: round-1 topology marked historical
(all merged+pushed), re-review findings table + plan pointers added.
55 KiB
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.Parsein Commons) +ReadEnumunification — 01 C-1/P-1, 05 — Medium/Medium") - Round-1 plans unified:
archreview/plans/01-core-composition-plan.md§2 (C-1) + §"P-1", andarchreview/plans/05-protocol-drivers-plan.mdPart 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.Contractsprojects + 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.Validateat init (FocasDriver.cs:115-119); equipment tags resolved via_parseRef(FocasDriver.cs:69) reachResolveParsedAddress(:243-247) with no matrix check — only the address-parse throw. - Modbus probe parity break:
ModbusDriverProbebinds the runtimeModbusDriverOptions(TimeSpan fields), not the factory DTO shape (ModbusDriverProbe.cs:19-24, 36) — a DB config withtimeoutMssilently 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)
Commonsis a near-leaf (src/Core/ZB.MOM.WW.OtOpcUa.Commons/): zeroProjectReferences, packages onlyAkka+ZB.MOM.WW.Audit. Houses the precedentTypes/EquipmentScriptPaths.cs— created exactly to de-duplicate a composer/artifact cross-seam parser. Referenced today byOpcUaServer,Runtime,ControlPlane— but NOT byConfiguration(which references onlyCore.Abstractions). AddingConfiguration → Commonsis cycle-safe (Commons references nothing in-repo).Core.Abstractionsis a pure leaf (zeroProjectReferences, zeroPackageReferences). HousesEquipmentTagRefResolver<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
.Contractsprojects are deliberately dependency-free:S7.ContractsandFOCAS.Contractscarry an explicit<!-- NO PackageReference. NO ProjectReference. -->banner; AbCip/AbLegacy/TwinCAT Contracts have no references;Modbus.Contractsreferences onlyModbus.Addressing. AdminUI references Contracts projects directly (the typed tag editors reuse driver enums) — this is why they must stay dependency-light. ACore.Abstractionsreference 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) runsDraftValidator.Validate(draft)and rejects on anyValidationError(recordCode/Message/Context— no severity field, no warnings channel exists today). Result message:StartDeploymentResult(Commons/Messages/Admin/StartDeploymentResult.cs) carries a free-textMessage.ControlPlanedoes not reference any driver Contracts project (csproj :22-28). - AdminUI-side validation:
AdminUI/Uns/TagEditors/TagConfigValidator.csdispatches byDriverType(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 —InternalsVisibleToat 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:
- FullName fallback: composer/artifact/walker fall back to the raw blob when
FullNameis absent/non-string/non-object/malformed (an equipment tag's TagConfig JSON is its wire reference for the six protocol drivers — the resolver's_parseRefpath depends on this).DraftValidatorreturnsnullon absent — it wants the explicit FullName only (Galaxy rule). - Null input: artifact tolerates
null(tagConfig ?? string.Empty); composer/walker take non-null (DBCK_Tag_TagConfig_IsJsonguarantees it).Parse(string?)returningFullName = tagConfig ?? string.Emptypreserves both.
Semantics to port verbatim (the byte-parity contract):
alarm: object-typedalarmproperty only;alarmTypestring default"AlarmCondition";severityint32 number default500;historizeToAvevabool? —nullunless explicitlytrue/false(absent/non-bool ⇒ null ⇒ default-on at the HistorianAdapterActor gate).isHistorized:trueonly when a booltrue;historianTagnamenon-whitespace string elsenull, not trimmed.isArray: bool;arrayLengthhonoured only whenisArrayis true AND a JSON number fittinguint(elsenull⇒ unboundedArrayDimensions=[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.
// 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-tagSelectcomputesvar intent = TagConfigIntent.Parse(t.TagConfig);once and projects all fields off it;AlarmmapsTagAlarmIntent → EquipmentTagAlarmInfo(one-line ctor call —EquipmentTagAlarmInfostays in OpcUaServer because the plan records and their consumers live there). The four private/internalExtract*statics are deleted.TryExtractDeviceHost/NormalizeDeviceHostdelegate to (then are replaced by)DeviceConfigIntent. - DeploymentArtifact (
Runtime): the four private mirrors are deleted; the tag-decode loop callsTagConfigIntent.Parseonce per tag element; the:837device-host call moves toDeviceConfigIntent.TryExtractHost.DriverHostActor(:742, :776) moves toDeviceConfigIntent.NormalizeHost. - DraftValidator (
Configuration): gains aProjectReferenceto 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 bothFullNameandExplicitFullName). - EquipmentNodeWalker (
Core):ExtractFullNamebody becomes a one-line delegate toTagConfigIntent.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 toTagConfigIntent.)
Alternatives considered and rejected:
- House the record in
Core.Abstractions— rejected:Configurationalready references it, butCommonsis 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
EquipmentTagAlarmInfoto 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)
- Characterization net first: add a golden TagConfig corpus (~30 blobs: happy path, blank/null, non-object root, malformed JSON,
FullNameabsent/non-string, alarm object with each field absent/wrong-type,historizeToAvevatrue/false/non-bool,historianTagnamewhitespace,isArray/arrayLengthcombinations incl. negative/overflow/non-number/length-without-flag, unknown keys, platform+driver keys mixed) and a compose→encode→decode parity test inRuntime.Tests(the existingDeploymentArtifact*ParityTestspattern) 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). - Add
Commons/Types/TagConfigIntent.cs+DeviceConfigIntent.cs, porting semantics verbatim from the composer (the canonical copy). Port the threeOpcUaServer.Tests/ExtractTag*Teststables intoCommons.Tests/TagConfigIntentTests— this becomes the single byte-parity authority. - Swap the composer (parse-once), keeping the parity corpus green. 4. Swap DeploymentArtifact. 5. Swap DraftValidator (+
Configuration → Commonsreference). 6. Swap the walker + repoint doc comments. 7. Re-home device-host (composer statics deleted;DriverHostActor/DeploymentArtifactcallers updated). 8. Retire the now-redundantOpcUaServer.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;ExplicitFullNamevsFullNamefallback distinction; never-throws property over the corpus.Commons.Tests/DeviceConfigIntentTests— ported from composer semantics +DeploymentArtifactDeviceHostParityTestsexpectations (normalization idempotence, null-on-blank).Runtime.Tests/Drivers/TagConfigCorpusParityTests— the cross-seam characterization (step 1); permanent regression guard.- Existing
DeploymentArtifact{Historize,Array,Alias,DeviceHost}ParityTestsmust 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)
// 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 PackageReferences and zero ProjectReferences (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: noCompile Includelinks anywhere insrc/); 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 viaJsonStringEnumConverter— 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
writablecheckbox 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-authoredwritablealready round-trips today. Live-verify caveat: AdminUI has no bUnit — the checkbox task requires a docker-dev/runverification pass when executed (out of this plan's static scope; flagged in the task).
Implementation steps
TagConfigJson+ tests inCore.Abstractions(helper is additive; nothing consumes it yet).- 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
ReadEnumwithTagConfigJson.ReadEnumOrDefault; addInspect(reference); honour/forcewritableper the table; delete the private copy. - FOCAS driver-side capability-gate wrap (+ test: capability-violating equipment ref fails resolve).
EquipmentTagConfigInspectorin ControlPlane +AdminOperationsActorwiring +TagConfigValidationModeoption. Consuming test through the actor, not just the map (the F10b/PR#423 lesson: unit-green ≠ wired).- Modbus probe DTO-shape fix +
timeoutMsround-trip test. - AdminUI
writablecheckboxes (mechanical; live-verify flagged). - Docs:
docs/Uns.mdTagConfig section (writable key, validation modes), migration note for the FOCAS writable flip, the phase-C follow-up recorded inarchreview/plans/STATUS.md.
Tests
Core.Abstractions.Tests/TagConfigJsonTests—TryReadEnumAbsent/Valid/Invalid matrix (case-insensitive match preserved),ReadWritableexplicit-false-only semantics,DescribeInvalidEnumlists valid names.- Per-driver strictness suite (each driver's existing
.Testsproject): (a) behavior-freeze — typo'd enum defaults exactly as today (Phase A pin); (b)Inspectreports the typo with field name + valid values; (c)writable:falsehonoured (AbCip regression; new for Modbus/S7/AbLegacy/TwinCAT); (d) FOCAS:Writablealways false +writable:truewarning; (e) absent enum ⇒ documented default (unchanged). - Draft-gate warning tests (ControlPlane/Admin tests — note:
*.Admin.Testsare 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
timeoutMsround-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