# PLAN-R2-08 — Conventions & Hygiene (Round-2 Findings NF1–NF8) > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Close the eight NEW findings from architecture review 08 round 2 (`archreview/08-conventions-tests-underdeveloped.md`, 2026-07-12) — rotate the live production Inbound API key sitting in `test.txt` at the repo root and purge the other plaintext credential captures (NF1, High), refresh the abandoned-and-now-wrong CHANGELOG (NF3), finish the options-validation tail (AuditLog's 6 sub-options + Host Node/Database/Logging + OperationTracking — NF4), re-consolidate deferral tracking into the canonical register (NF5), fix the stale Transport area comment (NF6), commit the orphaned MES plan doc + clean root clutter (NF7), and kill the duplicate/drifted Host options bindings (NF8). NF2 (failover envelope measurement) is **owned by PLAN-R2-01** — this plan only repairs the register row whose revisit trigger fired. **Architecture:** No new runtime components, no schema changes, no wire-contract changes. The options work copies the shipped `OptionsValidatorBase` + `AddValidatedOptions` pattern (`ZB.MOM.WW.Configuration`; see `AuditLog/ServiceCollectionExtensions.cs:81` and `KpiHistory/ServiceCollectionExtensions.cs:40`). The credential work is operational (dual-key rotation per `docs/requirements/Component-InboundAPI.md` §Key rotation, shipped by PLAN-06 T24) plus working-tree hygiene with a `.gitignore`/pre-commit guard. One discovery made while verifying NF4 is folded in as Task 8: the site-only `IOperationTrackingStore` appears to have **no DI registration anywhere in `src/`**, despite two comments claiming `AddSiteRuntime` registers it — Task 8 verifies and fixes that seam alongside the missing `OperationTrackingOptions` binding. **Tech Stack:** C# / .NET 10, xUnit, `Microsoft.Extensions.Options` (`IValidateOptions<>` + `ValidateOnStart`), `ZB.MOM.WW.Configuration` (`OptionsValidatorBase`, `AddValidatedOptions`), SQLite (site tracking store), ScadaBridge CLI (`security api-key` group), git/bash (hygiene tasks). **Build:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx`. Tests: `dotnet test ZB.MOM.WW.ScadaBridge.slnx` (all 30 test projects, incl. CLI.Tests). **Baseline:** `main` @ `8c888f13` (2026-07-12). Re-verify cited line numbers before editing — they were checked at plan-authoring time against this commit. ## Parallelization - **Task 1 FIRST** (NF1 is the P0; it needs the user/host, so start it immediately and let the wait overlap other tasks). Task 2 follows Task 1 (rotate before destroying the local record of what was exposed). - **Mutually parallel (file-disjoint):** 1, 3, 4, 5, 7, 10, 12, 13. - **Serial chains (shared files):** 5 → 6 (both edit `AuditLog/ServiceCollectionExtensions.cs`); 7 → 8 → 9 (all edit `Host/SiteServiceRegistration.cs`); 10 → 11 (delete the snapshot only after the register has absorbed its content). - Tasks 1, 2, 11, and the deletion half of 13 are flagged **`needs-user`** — they require production-host access and/or destroy untracked (unrecoverable) files, so the executor must show the user what is being done and get confirmation. --- ### Task 1: Rotate the exposed wonder-app-vd03 Inbound API key (dual-key flow) — `needs-user` **Classification:** NF1 — high-risk security hygiene; **`needs-user`** (production environment access; the executor runs the commands only with the user present/consenting, and cannot complete step 3's caller-cutover without the user) **Estimated implement time:** ~5 min of active work (cutover/quarantine waits excluded) **Parallelizable with:** 3, 4, 5, 7, 10, 12, 13 **Files:** - Modify: `docs/deployment/deployment-records/2026-06-27-wonder-app-vd03.md` (append a dated rotation record — the only repo artifact this task produces) **Context (verified):** `test.txt` (untracked, repo root) contains a **working production Inbound API key** for `http://wonder-app-vd03.zmr.zimmer.com:8085` — `sbk_eb5acc0b…` (full token on line 3) scoped at least to `MesMoveOut`. The key is live regardless of whether the file ever enters git history, so it must be **rotated**, not merely deleted. The zero-downtime dual-key procedure is already documented (`docs/requirements/Component-InboundAPI.md:75-86`, PLAN-06 T24): create → cutover → disable → quarantine → delete. This task is that procedure instantiated against wonder-app-vd03 as an executable checklist. Host facts (auto-memory `wonder-app-vd03-host-access`): the deployment runs with `DisableLogin=true`, so `:8085/management` is unauthenticated and the CLI works with just `--url`; host shell access (if needed) is servecli SSH port 2222. **Checklist (run each step, record output):** 1. Enumerate keys and identify the exposed one (match the `eb5acc0b` key-id prefix from `test.txt`; note its **name, key-id, and scope list**): ```bash scadabridge --url http://wonder-app-vd03.zmr.zimmer.com:8085 security api-key list ``` 2. Create the replacement with the **same scope set** (copy the old key's method list verbatim from step 1 — scopes are copied at creation only; drift between the pair is operator error): ```bash scadabridge --url http://wonder-app-vd03.zmr.zimmer.com:8085 security api-key create \ --name "-rot-2026-07" --methods "" ``` Capture the one-time `sbk_…` token from the output immediately (it is shown once). Do **not** write it to any file in this repo — hand it to the user directly in the conversation or via their chosen secret channel. 3. **USER ACTION — cutover:** the user delivers the new token to the calling system's owner (the MES/DELMIA-side integration that drives `MesMoveOut`) and confirms the caller has switched. Verify cutover from the audit log (the Actor field records the serving key per call): `scadabridge --url http://wonder-app-vd03.zmr.zimmer.com:8085 audit list --channel InboundApi --limit 20` (adjust flags to the real `audit` verbs) — recent `MesMoveOut` rows must show the **new** key id. 4. Disable (don't delete) the old key once cutover is confirmed: ```bash scadabridge --url http://wonder-app-vd03.zmr.zimmer.com:8085 security api-key update --key-id --enabled false ``` 5. Schedule deletion after a quarantine window (user judgment; suggest ≥ 1 week): ```bash scadabridge --url http://wonder-app-vd03.zmr.zimmer.com:8085 security api-key delete --key-id ``` 6. Append a dated "API-key rotation 2026-07" subsection to `docs/deployment/deployment-records/2026-06-27-wonder-app-vd03.md`: why (key exposed in a local scratch file, arch-review 08 round 2 NF1), old key id (id only — never the secret), new key name/id, cutover-confirmed date, disable date, planned delete date. Do NOT include any token material. 7. Verify (hygiene task — verification replaces a test): step 1 re-run shows the old key `Enabled=false` (or absent after step 5) and the new key present; `grep -c "sbk_" docs/deployment/deployment-records/2026-06-27-wonder-app-vd03.md` returns `0`. 8. Commit: ```bash git add docs/deployment/deployment-records/2026-06-27-wonder-app-vd03.md git commit -m "chore(hygiene): record wonder-app-vd03 inbound API-key rotation — exposed key retired via dual-key flow (plan R2-08 T1)" ``` If the user cannot reach wonder-app-vd03 in this session, record the checklist state (which steps are pending) in the deployment record with a `PENDING` marker and still commit — the rotation must not be silently forgotten. Task 2 may proceed regardless (deleting `test.txt` does not need the old secret; disable/delete key operations only need the key-id from `list`). --- ### Task 2: Delete `test.txt` and triage the root credential files — `needs-user` **Classification:** NF1 — high-risk security hygiene; **`needs-user`** (deletes untracked files — unrecoverable; user must see and confirm each) **Estimated implement time:** ~3 min **Parallelizable with:** 3, 4, 5, 7, 10, 12, 13 (blocked by 1 — rotate before destroying the local record; see Task 1's closing note for the pending-rotation escape hatch) **Files:** - Delete (untracked): `test.txt` - Delete (untracked+ignored, user-confirmed): `ldap_login.txt`, `sql_login.txt`, `sqllogin.txt` **Context (verified by reading each file):** - `test.txt` — the live `sbk_eb5acc0b…` production API key (Task 1's subject) plus an ad-hoc test note. Untracked and NOT gitignored — one `git add -A` (or the `pushit` skill, which stages everything) from entering history. - `ldap_login.txt` — **live AD credentials**: `zimdom.zimmer.com:3268` bind with the `SVC-DNC` service-account password AND the user's personal domain password. Ignored via `.gitignore:45` (`*login*.txt`) so unstageable, but plaintext on disk. - `sql_login.txt` — the wonder-app-vd03 SQL Server `wwadmin` password (the production ScadaBridge config DB). Ignored, plaintext. - `sqllogin.txt` — a provisioning transcript that creates `wwadmin` **with sysadmin** using the same password. Ignored, plaintext. None of the four are in git history (verified: untracked/ignored; `git log` shows no commits touching them), so this is disk cleanup, not history surgery. **Steps:** 1. Show the user the four files' contents (Read each aloud in the conversation) and state plainly: they are untracked — deletion is unrecoverable. Get explicit confirmation per file. If the user wants any kept, move it OUTSIDE the repo (e.g. `~/secrets/`) instead of leaving it in the working tree. 2. On confirmation: ```bash rm /Users/dohertj2/Desktop/ScadaBridge/test.txt rm /Users/dohertj2/Desktop/ScadaBridge/ldap_login.txt /Users/dohertj2/Desktop/ScadaBridge/sql_login.txt /Users/dohertj2/Desktop/ScadaBridge/sqllogin.txt ``` 3. **Advisory to relay to the user (do not action without them):** the SVC-DNC, personal-domain, and `wwadmin` passwords in those files were exposed on disk for ~7 weeks; recommend rotating them through the normal AD/SQL channels. The `wwadmin` password also appears in the on-host deployment; rotating it means updating `appsettings` connection strings on wonder-app-vd03 (`deploy/wonder-app-vd03/RUNBOOK.md` procedure). 4. Verify: `ls test.txt ldap_login.txt sql_login.txt sqllogin.txt 2>&1` — all four report "No such file". `git status --short` no longer lists `test.txt`. 5. No commit — the deletions touch only untracked files (Task 3 commits the guard that prevents recurrence). --- ### Task 3: Root secret-capture guards — `.gitignore` patterns + pre-commit secret scan **Classification:** small (hygiene guard) **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12 **Files:** - Modify: `.gitignore` (currently 48 lines; the `*login*.txt` rule is at line 45) - Create: `tools/hooks/pre-commit` (secret-scan hook) and `tools/hooks/README.md` (2-line install note) **Steps:** 1. Extend `.gitignore` — after the existing `*login*.txt` block (line 45), add: ```gitignore # Ad-hoc scratch/credential captures at the repo root — never commit # (arch-review 08 round 2 NF1: a live API key sat in an untracked test.txt) /test.txt /*credentials* /*secret* # Claude tool worktrees (NF7) .claire/ ``` Keep patterns root-anchored (`/`) so legitimate `src/`/`tests/` files named e.g. `SecretsBlock.cs` are unaffected. 2. Create `tools/hooks/pre-commit` (mode 755) — a ~20-line POSIX-sh scan of the **staged diff** that blocks the commit when it introduces obvious credential material: - an inbound API token: `sbk_[0-9a-f]{16,}_[A-Za-z0-9_-]{20,}` - a plaintext password assignment in root-level `.txt`/`.md` additions: `^\+.*[Pp]assword[0-9]*\s*[:=]\s*\S` Exit non-zero with a message naming the offending file/pattern and how to bypass (`git commit --no-verify`) for a knowing false positive. Scan only `git diff --cached -U0` output — never the whole tree (speed + no false hits on committed test fixtures). 3. `tools/hooks/README.md`: two lines — what the hook does, and the opt-in install command `git config core.hooksPath tools/hooks` (hooks are not tracked by git; this is a suggestion the user enables once, exactly the "pre-commit suggestion" the report asked for). 4. Verify (guard task — a real fail→pass exercise replaces a unit test): ```bash git check-ignore -v test.txt .claire/ # both match the new rules printf 'api key: sbk_%s_%s\n' "$(printf 'a%.0s' {1..32})" "$(printf 'b%.0s' {1..40})" > /tmp/nf1-probe.txt cp /tmp/nf1-probe.txt probe-secret-scan.md && git add probe-secret-scan.md sh tools/hooks/pre-commit; echo "hook exit: $?" # expect non-zero (blocked) git reset probe-secret-scan.md && rm probe-secret-scan.md /tmp/nf1-probe.txt sh tools/hooks/pre-commit; echo "hook exit: $?" # clean index → expect 0 ``` 5. Commit: ```bash git add .gitignore tools/hooks git commit -m "chore(hygiene): root secret-capture guards — .gitignore patterns + opt-in pre-commit secret scan (plan R2-08 T3)" ``` --- ### Task 4: CHANGELOG.md — correct the false role claim and refresh to reality **Classification:** trivial (documentation) **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13 **Files:** - Modify: `CHANGELOG.md` (last touched 2026-06-02, `b104760b`; the false claim is at lines 11-12) **Steps:** 1. Fix the falsehood first — lines 11-12 assert "`Operator`/`Engineer` are unused by ScadaBridge", false since M7 shipped the secured-write flow. Replace the parenthetical with: > (`Engineer` remains unused by ScadaBridge; `Operator` has since been adopted by the M7 two-person secured-write flow, together with the new `Verifier` role — see the M7 entry above) 2. Add new entries ABOVE the existing `[Unreleased]` content (keep-a-changelog style, newest first, still under `[Unreleased]` since nothing is version-tagged). Keep it honest and SHORT — 3 summary blocks, a few bullets each, each pointing at the authoritative record rather than duplicating it: - **2026-07-08 → 2026-07-10 — Architecture-review hardening initiative (PLAN-01…PLAN-08).** One paragraph: 8 reviews → 8 fix plans, 191/192 tasks landed (options validation across 12+ components, ClusterClient contract-lock tests, secured-write TTL, login throttle, transport single-flight, S&F sweep bounding, deploy-readiness handshake, vestigial site-notification surface excised, deferred-work register established). Authoritative record: `archreview/plans/00-MASTER-TRACKER.md` + the per-plan docs. - **M8 — Transport.** Encrypted bundle export/import extended to site/instance-scoped config with `BundleNameMap` reconciliation, per-line Myers diff, script trust gate at import. Post-initiative: aggregated live alarm stream + KPI hourly rollups (both 2026-07-10). - **M7 — Native alarms + operational UX.** Native alarm mirroring (OPC UA A&C + MxGateway), Alarm Summary page, OPC UA browse/search/cert-trust, **two-person secured writes introducing the `Operator`/`Verifier` roles**, SMS notification adapter. 3. Add one closing line under the changelog intro: detailed per-change history lives in `git log`, `archreview/plans/00-MASTER-TRACKER.md`, and `docs/plans/` — this file records operator-facing/breaking changes only. 4. Verify: `grep -n "Engineer remains unused\|Verifier" CHANGELOG.md` hits; `grep -n "are unused by ScadaBridge" CHANGELOG.md` → no hit for the old combined claim; the two BREAKING sections (roles, inbound-API auth) are preserved untouched below the new entries. 5. Commit: ```bash git add CHANGELOG.md git commit -m "docs(changelog): refresh to reality — fix false Operator/Engineer claim, summarize arch-review initiative + M7/M8 (plan R2-08 T4)" ``` --- ### Task 5: Options validation — AuditLog site sub-options (SiteWriter, SiteTelemetry, SiteRetention) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 7, 10, 11, 12, 13 (NOT 6 — both edit `AuditLog/ServiceCollectionExtensions.cs`) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriterOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/Telemetry/SiteAuditTelemetryOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SiteAuditRetentionOptionsValidator.cs` - Modify: `src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs` (lines 100-108 — the three `AddOptions().Bind(...)` calls) - Test: `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterOptionsValidatorTests.cs`, `.../Site/SiteAuditTelemetryOptionsValidatorTests.cs`, `.../Site/SiteAuditRetentionOptionsValidatorTests.cs` The pattern to copy is in the SAME file: `AuditLog/ServiceCollectionExtensions.cs:81` already does `services.AddValidatedOptions(config, ConfigSectionName)` — the shared helper (scadaproj `ZB.MOM.WW.Configuration/ServiceCollectionExtensions.cs:28-41`) binds via `config.GetSection(sectionPath)` (preserving the file's documented partial-config-root rationale at lines 96-99), chains `.ValidateOnStart()`, and `TryAddEnumerable`s the validator. Validator + test shape: `AuditLog/Configuration/AuditLogOptionsValidator.cs` / `AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs`. 1. Write failing tests first (one `DefaultOptions_AreValid` + 2-3 rejection cases per validator, `new XValidator().Validate(Options.DefaultName, options)` shape). Rejection cases (fields verified against the option classes): - `SqliteAuditWriterOptions`: `DatabasePath = ""`, `ChannelCapacity = 0`, `BatchSize = 0`, `FlushIntervalMs = 0`. **Do NOT validate `BacklogPollIntervalSeconds`** — non-positive has a documented fall-back-to-30s contract (register row 21 resolution + cadence tests); note this in a validator comment. - `SiteAuditTelemetryOptions`: `BatchSize = 0`, `BusyIntervalSeconds = 0`, `IdleIntervalSeconds = 0`. Read `SiteAuditTelemetryActor`'s tick usage first; if idle < busy is genuinely nonsensical there, add the `IdleIntervalSeconds >= BusyIntervalSeconds` cross-check too. - `SiteAuditRetentionOptions`: `RetentionDays = 0`, `PurgeInterval = TimeSpan.Zero`, `InitialDelay = TimeSpan.FromSeconds(-1)` (require `>= TimeSpan.Zero`). Leave `PurgeIntervalOverride` unvalidated — the class remarks mark it test-only, not config-set. 2. Run, expect FAIL (compile error on missing validator types counts): ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests --filter "FullyQualifiedName~SqliteAuditWriterOptionsValidator" ``` 3. Implement the three validators (`OptionsValidatorBase`, key-naming messages using the real section names from the SCE constants — `AuditLog:SiteWriter`, `AuditLog:SiteTelemetry`, `AuditLog:SiteRetention`). Convert the bindings at lines 100-108 to: ```csharp services.AddValidatedOptions(config, SiteWriterSectionName); services.AddValidatedOptions(config, SiteTelemetrySectionName); services.AddValidatedOptions(config, SiteAuditRetentionOptions.SectionName); ``` Keep (condense, don't delete) the surrounding why-comments — the partial-config-root note and the "inert on central" retention note still hold. 4. Run, expect PASS; then guard both composition roots (central also calls `AddAuditLog`, so defaults must validate clean there): ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests --filter "FullyQualifiedName~OptionsValidator" dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~CompositionRoot" ``` 5. Commit: ```bash git add src/ZB.MOM.WW.ScadaBridge.AuditLog tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests git commit -m "fix(options): eager startup validation for AuditLog site sub-options — SiteWriter, SiteTelemetry, SiteRetention (plan R2-08 T5, arch-review 08r2 NF4)" ``` --- ### Task 6: Options validation — AuditLog central sub-options (PartitionMaintenance, Purge, Reconciliation) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 7, 10, 11, 12, 13 (blocked by 5 — same `ServiceCollectionExtensions.cs`) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPartitionMaintenanceOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/SiteAuditReconciliationOptionsValidator.cs` - Modify: `src/ZB.MOM.WW.ScadaBridge.AuditLog/ServiceCollectionExtensions.cs` (lines 368-390 in `AddAuditLogCentralMaintenance` — re-check offsets after Task 5's edit) - Test: `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPartitionMaintenanceOptionsValidatorTests.cs`, `.../Central/AuditLogPurgeOptionsValidatorTests.cs`, `.../Central/SiteAuditReconciliationOptionsValidatorTests.cs` Same recipe as Task 5. Rejection cases (fields verified): - `AuditLogPartitionMaintenanceOptions` (`AuditLog:PartitionMaintenance`): `IntervalSeconds = 0`, `LookaheadMonths = 0`. - `AuditLogPurgeOptions` (`AuditLog:Purge`): `IntervalHours = 0`, `ChannelPurgeBatchSizeConfigured = 0`, `MaintenanceCommandTimeoutMinutes = 0`. Leave `IntervalOverride` unvalidated (test-only per class remarks). - `SiteAuditReconciliationOptions` (`AuditLog:Reconciliation`): `ReconciliationIntervalSeconds = 0`, `BatchSize = 0`, `StalledAfterNonDrainingCycles = 0`. Leave `ReconciliationIntervalOverride` unvalidated. 1. Failing tests → 2. run one filter, expect FAIL → 3. implement validators + convert the three `AddOptions().Bind(...)` at :368-390 to `AddValidatedOptions` with the existing section constants (`PartitionMaintenanceSectionName`, `PurgeSectionName`, `ReconciliationSectionName`); **keep the long I1-review comment block at :372-384** — it explains WHY the two singleton options bind here, which is unchanged → 4. run, expect PASS: ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests --filter "FullyQualifiedName~OptionsValidator" dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~CompositionRoot" ``` 2. Commit: ```bash git add src/ZB.MOM.WW.ScadaBridge.AuditLog tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests git commit -m "fix(options): eager startup validation for AuditLog central sub-options — PartitionMaintenance, Purge, Reconciliation (plan R2-08 T6, arch-review 08r2 NF4)" ``` --- ### Task 7: Options validation — Host NodeOptions / DatabaseOptions / LoggingOptions (empty NodeName fails fast) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 5, 6, 10, 11, 12, 13 (NOT 8/9 — all three edit `Host/SiteServiceRegistration.cs`) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.Host/NodeOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.Host/DatabaseOptionsValidator.cs`, `src/ZB.MOM.WW.ScadaBridge.Host/LoggingOptionsValidator.cs` - Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` (`BindSharedOptions`, lines 160/165/172 — the plain `services.Configure<>` calls for these three) - Modify tests: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs` (in-memory config dicts at ~:111 and ~:350), `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/MetricsEndpointTests.cs` (~:43) — none currently sets `ScadaBridge:Node:NodeName` - Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/NodeOptionsValidatorTests.cs` (one file covering all three validators is fine) This is the SourceNode failure mode the tracker logged for wonder-app-vd03: an empty `NodeOptions.NodeName` "normalises to a NULL SourceNode" (the appsettings `_nodeName` comment says so verbatim) — it must fail the host at boot, not silently degrade the audit trail. The Host csproj already references `ZB.MOM.WW.Configuration` transitively via components, but add the explicit `` (no Version — central package management) if `dotnet build` says otherwise. 1. Write failing validator tests. Rejection cases: - `NodeOptions`: `NodeName = ""` (and whitespace) rejected — message must say what breaks ("SourceNode audit column stamps NULL"). `RemotingPort`/`GrpcPort`/`MetricsPort` outside `0..65535` rejected (**0 must stay VALID** — `CompositionRootTests` uses `RemotingPort = 0` for dynamic ports). - `DatabaseOptions`: all three fields are nullable and role-dependent — reject only whitespace-non-null values (e.g. `ConfigurationDb = " "`); null stays valid. - `LoggingOptions`: `MinimumLevel = "Chatty"` rejected. First read how `MinimumLevel` is consumed (Program.cs Serilog bootstrap) and validate against that parser's accepted set (Serilog: Verbose/Debug/Information/Warning/Error/Fatal, case-insensitive). 2. Run, expect FAIL: ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~NodeOptionsValidator" ``` 3. Implement the three validators (`OptionsValidatorBase`, key prefix `ScadaBridge:Node` / `ScadaBridge:Database` / `ScadaBridge:Logging`). Convert `BindSharedOptions` lines 160/165/172 to the `AddOptions().Bind(...).ValidateOnStart()` + `TryAddEnumerable` shape (exactly what the same method already does for `ClusterOptions` at :164 and `HealthMonitoringOptions` at :170 — mirror those, comments included). 4. Add `["ScadaBridge:Node:NodeName"] = "central-a"` (central fixtures) / `"node-a"` (site fixtures) to every in-memory Host-boot config dict — grep the test project for `ScadaBridge:Node:` to find them all; the production `appsettings.{Central,Site}.json` and all docker per-node configs already set NodeName (verified), so no config-file changes are needed. 5. Run, expect PASS: ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~NodeOptionsValidator|FullyQualifiedName~CompositionRoot|FullyQualifiedName~MetricsEndpoint" dotnet build ZB.MOM.WW.ScadaBridge.slnx ``` 6. Commit: ```bash git add src/ZB.MOM.WW.ScadaBridge.Host tests/ZB.MOM.WW.ScadaBridge.Host.Tests git commit -m "fix(options): validate Host Node/Database/Logging options at startup — empty NodeName fails fast instead of NULLing SourceNode (plan R2-08 T7, arch-review 08r2 NF4)" ``` --- ### Task 8: OperationTrackingOptions binding + the missing site `IOperationTrackingStore` registration (verify-then-fix) **Classification:** standard — carries a **plan-authoring discovery** that may be a live functional gap, so investigate before coding **Estimated implement time:** ~5 min (after the ~5 min verification below confirms the gap) **Parallelizable with:** 1, 2, 3, 4, 5, 6, 10, 11, 12, 13 (blocked by 7 — same `Host/SiteServiceRegistration.cs`) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Tracking/OperationTrackingOptionsValidator.cs` - Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ServiceCollectionExtensions.cs` (`AddSiteRuntime(connectionString)`, lines 36-82 — add the store registration) - Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` (site-options block ~:134-152 — add the binding) - Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Tracking/OperationTrackingOptionsValidatorTests.cs`; extend `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs` (site-role fixture) **Discovery (verified at plan time, main @ 8c888f13):** NF4 notes `OperationTrackingOptions` has no binding site. Verifying that surfaced something bigger: **no code anywhere in `src/` registers `IOperationTrackingStore` in DI** — yet `Host/Actors/AkkaHostedService.cs:1044` says "The store is registered by AddSiteRuntime on site composition roots" and `AuditLog/ServiceCollectionExtensions.cs:158-159` says "the operational tracking store is SITE-ONLY (registered by ZB.MOM.WW.ScadaBridge.SiteRuntime)". `AddSiteRuntime` (SiteRuntime SCE :36-82) registers no such thing. Every consumer resolves it with `GetService` (null-tolerant): `AkkaHostedService.cs:1048` (cached-drain scheduler never armed when null), `:1097-1099` (`PullSiteCalls` reconciliation seam never wired), `ScriptExecutionActor.cs:198` (`Tracking.Status` degraded), `AuditLog SCE :166` (forwarder degrades to audit-only). If the registration is truly absent, site-local cached-call tracking has been running in its degraded mode. 1. **Verify first** (do not trust this plan's grep over live behavior): `grep -rn "IOperationTrackingStore" src --include="*.cs"` and confirm no `AddSingleton`/descriptor registration exists. If one exists somewhere this plan missed — this task shrinks to the options binding + validator only; update the task notes accordingly. 2. Write the failing composition-root lock — in the Host.Tests **site-role** fixture: ```csharp [Fact] public void Site_Resolves_IOperationTrackingStore() { // Site Call Audit's site-local source of truth. AkkaHostedService and // ScriptRuntimeContext resolve this with GetService and silently degrade // when absent (audit-only telemetry, no cached-drain, no PullSiteCalls) — // so its presence on the SITE composition root must be locked here. var store = _host.Services.GetService(); Assert.NotNull(store); } ``` Plus validator rejection tests: `ConnectionString = ""`, `RetentionDays = 0` (SiteRuntime.Tests; SiteRuntime already references `ZB.MOM.WW.Configuration` — `SiteRuntimeOptionsValidator` lives there). 3. Run, expect FAIL: ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~Site_Resolves_IOperationTrackingStore" ``` 4. Implement: - `OperationTrackingOptionsValidator` (`ScadaBridge:OperationTracking` key prefix; `ConnectionString` non-empty, `RetentionDays > 0`). - In `AddSiteRuntime(connectionString)`: `services.AddSingleton();` (ctor takes `IOptions` + `ILogger` — verified at `OperationTrackingStore.cs:58-60`). Note in a comment that this makes the two existing "registered by AddSiteRuntime" claims true again. - In `Host/SiteServiceRegistration.cs` site-options block (~:134-152): `AddOptions().Bind(config.GetSection("ScadaBridge:OperationTracking")).ValidateOnStart()` + `TryAddEnumerable` the validator — same shape as its `SiteRuntimeOptions` neighbor. - Check `OperationTrackingStore`'s construction path: if the ctor eagerly opens/creates the SQLite file, point the Host.Tests site fixture at a temp path (`["ScadaBridge:OperationTracking:ConnectionString"] = $"Data Source={Path.Combine(Path.GetTempPath(), ...)}"`) so test runs don't litter `site-tracking.db` in the test cwd. 5. Run, expect PASS — and because this un-degrades real behavior, run the consumer suites too: ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~CompositionRoot" dotnet test tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests --filter "FullyQualifiedName~Tracking" dotnet test tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests --filter "FullyQualifiedName~Telemetry" ``` 6. If the gap was real (step 1 confirmed), flag it in the commit body — it is a functional fix riding a hygiene plan, and the user should know: ```bash git add src/ZB.MOM.WW.ScadaBridge.SiteRuntime src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests tests/ZB.MOM.WW.ScadaBridge.Host.Tests git commit -m "fix(options): bind + validate OperationTrackingOptions; register the site IOperationTrackingStore that two comments claimed AddSiteRuntime provides (plan R2-08 T8, arch-review 08r2 NF4)" ``` --- ### Task 9: NF8 — canonicalize the Communication/DataConnection section names, drop the duplicate Host bindings **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 5, 6, 10, 11, 12, 13 (blocked by 8 — same `Host/SiteServiceRegistration.cs`) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Communication/ServiceCollectionExtensions.cs` (line 16), `src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs` (message prefixes) - Modify: `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/ServiceCollectionExtensions.cs` (lines 18, 27-31), `src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/DataConnectionOptionsValidator.cs` (message prefixes, if any name the section) - Modify: `src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs` (delete lines 140, 166, 171 — re-check offsets after Tasks 7/8) - Test: extend `tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs` (or a new `CommunicationOptionsBindingTests.cs`) and the DCL equivalent **Context (verified — the drift NF8 warned about has already happened):** every real appsettings file nests these sections under `ScadaBridge:` (`Host/appsettings.Site.json:2` root, `Communication` at :37, `DataConnection` at :28; same in all docker per-node configs). But `AddCommunication()` binds `BindConfiguration("Communication")` (Communication SCE :16) and `AddDataConnectionLayer()` binds `"DataConnectionLayer"` (DCL SCE :18) — **sections that exist nowhere**. The options only get real values because the Host's duplicate `Configure<>` calls (`SiteServiceRegistration.cs:140/:166`) bind the true `ScadaBridge:*` sections. So "delete the Host copy" alone (the report's shorthand) would silently revert both components to defaults — the fix is: canonicalize the SCE section names to the real ones, THEN delete the Host duplicates. `NotificationOptions` is the clean case: its SCE already binds `"ScadaBridge:Notification"` (NotificationService SCE :23), so the Host duplicate at :171 is purely redundant — but `AddNotificationService()` is central-only (site path comment at `SiteServiceRegistration.cs:37-41`), so before deleting :171, grep for any site-path consumer of `IOptions`; if none exists (expected — sites don't deliver), delete it. 1. Write the failing binding test (Communication.Tests): ```csharp [Fact] public void AddCommunication_BindsTheScadaBridgeCommunicationSection() { // The real appsettings nest this section under "ScadaBridge:" — binding a // root-level "Communication" section reads config that no deployment writes // (arch-review 08 round 2 NF8: the Host duplicate was silently supplying the // real values). AddCommunication alone must bind the canonical section. var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ScadaBridge:Communication:DeploymentTimeout"] = "00:01:23", }).Build(); var services = new ServiceCollection(); services.AddSingleton(config); services.AddCommunication(); using var sp = services.BuildServiceProvider(); Assert.Equal(TimeSpan.FromSeconds(83), sp.GetRequiredService>().Value.DeploymentTimeout); } ``` Mirror for DCL (`ScadaBridge:DataConnection:ReconnectInterval`). Run, expect FAIL (the SCE binds the wrong section, so the value stays at default). 2. Implement: - Communication SCE :16 → `.BindConfiguration("ScadaBridge:Communication")`; update every `Communication:` message prefix in `CommunicationOptionsValidator` to `ScadaBridge:Communication:` (the messages exist to name the real config key). - DCL SCE :18 → `.BindConfiguration("ScadaBridge:DataConnection")`; same prefix sweep in its validator. For `OpcUaGlobalOptions`/`MxGatewayGlobalOptions` (:27-31, bare `"OpcUa"`/`"MxGateway"`): grep repo-wide (appsettings + tests + docs) for those bare sections first; no hit is expected (verified: none in any appsettings) — then canonicalize to `"ScadaBridge:OpcUa"`/`"ScadaBridge:MxGateway"` and note the sections in the two options classes' XML docs. - Delete the three Host duplicates (`SiteServiceRegistration.cs:140` DataConnection, `:166` Communication, `:171` Notification — after the consumer grep above). 3. Run, expect PASS — plus both composition roots (this is exactly the change that could silently zero a timeout if the section name is wrong): ```bash dotnet test tests/ZB.MOM.WW.ScadaBridge.Communication.Tests --filter "FullyQualifiedName~Options" dotnet test tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests --filter "FullyQualifiedName~Options" dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests --filter "FullyQualifiedName~CompositionRoot" dotnet build ZB.MOM.WW.ScadaBridge.slnx ``` 4. Live-config sanity (cheap, catches a typo'd section name better than any unit test): `bash docker/deploy.sh` is NOT required for this plan, but at minimum re-read one docker site config and one central config and confirm every key under `ScadaBridge:Communication` / `ScadaBridge:DataConnection` corresponds to a `CommunicationOptions`/`DataConnectionOptions` property. 5. Commit: ```bash git add src/ZB.MOM.WW.ScadaBridge.Communication src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer src/ZB.MOM.WW.ScadaBridge.Host/SiteServiceRegistration.cs tests/ZB.MOM.WW.ScadaBridge.Communication.Tests tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests git commit -m "fix(options): canonicalize Communication/DataConnection config sections to ScadaBridge:*; drop the duplicate Host bindings that were masking the drift (plan R2-08 T9, arch-review 08r2 NF8)" ``` --- ### Task 10: Re-consolidate deferral tracking into the canonical register **Classification:** trivial (documentation) **Estimated implement time:** ~5 min **Parallelizable with:** 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 13 **Files:** - Modify: `docs/plans/2026-07-08-deferred-work-register.md` (Fix-now table lines 7-16; "New deferrals" table lines 45-51) - Modify: `archreview/plans/00-MASTER-TRACKER.md` (registry section, lines 137-147) Four edits, all specified by NF5 + the register's own rules: 1. **Prune the landed Fix-now rows** (the register's stated rule: "removed from this table when that plan's task lands" — all 7 landed). Replace the Fix-now table body with a single line: `All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round 2, 2026-07-12) — rows removed per the rule above; see the round-2 report §1 for the per-item evidence.` 2. **Add the two tracker-only live items as first-class register rows** (a new `## Deferred — operational risk (from the initiative tracker, folded in 2026-07-12)` section, same column shape as the Deferred table): - **SBR oldest-crash total-outage gap** — 2-node `keep-oldest` downs the partition without the oldest, so a hard crash of the ACTIVE (oldest) central node makes the standby self-down (~10s) → total central outage until restart; only a younger-node crash fails over. Where noted: `archreview/plans/00-MASTER-TRACKER.md:143` + auto-memory. Rationale: remedy is a production SBR topology/strategy decision (keep-majority + 3rd lighthouse node, static-quorum, or an accepted-risk note) — **owner: user decision**, not silently changeable. Revisit trigger: before the next production deployment that adds a central node, or the first real active-node crash. - **`deploy/wonder-app-vd03/` overlay edits unapplied** — `appsettings.Central.json` needs `AllowSingleNodeCluster: true` + phantom-seed removal + `NodeName: central-a`; `install.ps1` needs `sc.exe failure` recovery actions. The artifact directory is intentionally untracked (production config out of source control), so the repo cannot ship the fix. Where noted: `00-MASTER-TRACKER.md:147` (PLAN-01 T16/T20/T23). Rationale: needs on-host access; without `NodeName` that deployment's audit rows stamp NULL `SourceNode` (partially mitigated once Task 7 lands — the host will now **fail at boot** with a key-naming error instead of silently NULLing, so applying the overlay becomes mandatory at next upgrade). Owner: whoever maintains the host (user). Revisit trigger: next wonder-app-vd03 deployment/upgrade — **the Task 7 validator makes this row unskippable then**. 3. **Fix the fired revisit trigger** — the "Failover-timing + broader perf envelope" row (line 51): its trigger "PLAN-01 rig landing" fired on 2026-07-08 (`tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/Cluster/TwoNodeClusterFixture.cs`), before the placeholder even shipped, and nobody noticed (NF2). Rewrite the row: status → **trigger fired 2026-07-08**; owner → **PLAN-R2-01** (the round-2 cluster/host/failover plan owns wiring `FailoverTimingTests` to the fixture or documenting the true blocker — reference its failover-envelope measurement task by number once that plan file exists; if PLAN-R2-01 is not yet written when this task executes, cite it as `archreview/plans/PLAN-R2-01-*.md (failover-envelope measurement task)`). 4. **Subordinate the tracker's registry** — in `00-MASTER-TRACKER.md`, immediately under the `## Deferred / Won't-Fix Registry (initiative-level)` heading (line 137), add: `> 2026-07-12 (plan R2-08 T10): the two live items below (SBR oldest-crash gap, wonder-app-vd03 overlay) now have canonical rows in docs/plans/2026-07-08-deferred-work-register.md — that register is the single tracking place; the subsections below remain as the historical narrative.` Do not delete the narrative subsections (they carry the full evidence). 5. Verify: `grep -n "SBR\|wonder-app-vd03" docs/plans/2026-07-08-deferred-work-register.md` → both new rows present; `grep -c "PLAN-08 Task 1 |" docs/plans/2026-07-08-deferred-work-register.md` → 0 (Fix-now rows gone); `grep -n "PLAN-R2-01" docs/plans/2026-07-08-deferred-work-register.md` → the repointed trigger row; `grep -n "single tracking place" archreview/plans/00-MASTER-TRACKER.md` → the subordination note. 6. Commit: ```bash git add docs/plans/2026-07-08-deferred-work-register.md archreview/plans/00-MASTER-TRACKER.md git commit -m "docs(plans): re-consolidate deferral tracking — SBR gap + wonder-app overlay rows into the register, prune landed Fix-now rows, repoint the fired perf-envelope trigger at PLAN-R2-01 (plan R2-08 T10, arch-review 08r2 NF5)" ``` --- ### Task 11: Delete the drifted root `deferred.md` snapshot — `needs-user` **Classification:** trivial (hygiene); **`needs-user`** (deletes an untracked file — unrecoverable) **Estimated implement time:** ~2 min **Parallelizable with:** 1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 13 (blocked by 10 — the register must absorb/verify everything first) **Files:** - Delete (untracked): `deferred.md` (repo root) `deferred.md` is a 2026-07-10 snapshot of the register that has already drifted (it counts "12 open deferrals" vs the register's ~10, restructures the sections, and its two ✅-SHIPPED rows duplicate the register's Resolved table). Duplicated state is how the trigger in NF2 got missed — it goes. 1. Diff it against the register for the user's benefit and confirm **nothing in `deferred.md` is absent from the post-Task-10 register** (the only unique content at plan-authoring time was cosmetic regrouping + the shipped-row prose, both already in the register's Resolved section). If the executor finds a genuinely unique fact, fold it into the register first (amend Task 10's commit). 2. Show the user the file (it is untracked — deletion is unrecoverable) and get confirmation. 3. `rm /Users/dohertj2/Desktop/ScadaBridge/deferred.md` 4. Verify: `ls deferred.md 2>&1` → no such file; `git status --short` no longer lists it. No commit (untracked file). --- ### Task 12: Fix the stale Transport area comment in `EntitySerializer.FromBundleContent` **Classification:** trivial (comment drift) **Estimated implement time:** ~3 min **Parallelizable with:** 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13 **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs` (lines 553-554) `:553-554` still says "Areas don't travel (see export TODO), so AreaId stays null" — but areas DO travel by name since PLAN-05 (export resolves `AreaId` → `AreaNameById` at `:265-273`; the importer resolves-or-creates), and no TODO exists anymore. The `AreaId = null` at `:572` is still intentionally correct for this helper — `FromBundleContent` is off the importer's path (BundleImporter walks the raw DTO) and has no target-DB area-name→id map, exactly like the placeholder `DataConnectionId = 0` the same comment block already explains for connection bindings. 1. Replace the stale sentence with the truth, riding the existing explanation: ```csharp // Instances: resolve template + site by name/identifier. Areas DO travel — // by NAME on InstanceDto.AreaName (export resolves AreaId→AreaNameById; the // importer resolves-or-creates under the target site). This helper still // materializes AreaId = null for the same reason the connection-binding FK // below is a placeholder: FromBundleContent is NOT on the importer's path // (BundleImporter walks the raw DTO) and has no target-DB name→id map to // resolve against. Round-trip fidelity for the area lives on the DTO, not // on this materialized entity. Connection bindings keep their ... ``` (Splice so the existing binding explanation at :555-562 continues unduplicated.) 2. Verify (comment task — verification replaces a test): ```bash grep -n "export TODO" src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs # expect: no hits grep -c "Areas DO travel" src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs # expect: 1 dotnet build src/ZB.MOM.WW.ScadaBridge.Transport ``` 3. Commit: ```bash git add src/ZB.MOM.WW.ScadaBridge.Transport/Serialization/EntitySerializer.cs git commit -m "chore(hygiene): fix stale area-export comment in EntitySerializer.FromBundleContent — areas travel by name since PLAN-05 (plan R2-08 T12, arch-review 08r2 NF6)" ``` --- ### Task 13: Commit the orphaned MES plan doc; remove the generated root reports — `needs-user` (deletions only) **Classification:** trivial (hygiene); the two report deletions are **`needs-user`** (untracked — unrecoverable) **Estimated implement time:** ~3 min **Parallelizable with:** 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 **Files:** - Commit (currently untracked): `docs/plans/2026-06-30-mes-alarm-status-api.md` - Delete (untracked, user-confirmed): `ScadaBridge-docs-fixed.md`, `ScadaBridge-docs-issues.md` (root) - (`.claire/` is handled by Task 3's `.gitignore` entry — do not delete the directory; it holds tool worktrees) 1. The MES plan doc already carries the right header (`**Status:** Draft plan — NOT yet executed`), so it needs no edits — the repo's docs-as-spec convention says plan docs are committed decision records, and untracked it is invisible to every other session and one `git clean` from gone: ```bash git add docs/plans/2026-06-30-mes-alarm-status-api.md git commit -m "docs(plans): commit the 2026-06-30 MES alarm-status API draft plan — untracked decision record (plan R2-08 T13, arch-review 08r2 NF7)" ``` 2. `ScadaBridge-docs-fixed.md` (4 KB) / `ScadaBridge-docs-issues.md` (166 KB) are generated CommentChecker-style reports from 2026-07-10, already consumed. Show the user their first ~20 lines each, state they are untracked/unrecoverable, and on confirmation: ```bash rm /Users/dohertj2/Desktop/ScadaBridge/ScadaBridge-docs-fixed.md /Users/dohertj2/Desktop/ScadaBridge/ScadaBridge-docs-issues.md ``` If the user wants them kept, move them to the session scratchpad or `~/` — not the repo root. 3. Verify: `git status --short` shows neither report nor the plan doc as untracked; `git log --oneline -1 -- docs/plans/2026-06-30-mes-alarm-status-api.md` shows the new commit. --- ## Dependencies on other plans - **PLAN-R2-01 (cluster/host/failover round 2)** owns NF2 in full: wiring `tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/Failover/FailoverTimingTests.cs:26-28` (whose skip reason is now stale) to the `TwoNodeClusterFixture` rig — or updating the skip text + register row with the true blocker. This plan's only NF2 touchpoint is Task 10 step 3, which repoints the register row's fired trigger at that plan's measurement task. **No file overlap** with PLAN-R2-01 is expected except `00-MASTER-TRACKER.md` (Task 10 adds one blockquote line) — coordinate merge order if both plans run concurrently. - No other round-2 plan shares files with this one at plan-authoring time; Tasks 7-9 all edit `Host/SiteServiceRegistration.cs`, which round-1 plans also touched — they are merged, so only the intra-plan 7→8→9 serialization matters. ## Execution order 1. **Task 1 first** (P0; starts the user-dependent rotation clock), Task 2 after it. 2. **Tasks 3, 4, 5, 7, 10, 12, 13 in parallel** (disjoint files). 3. **Task 6 after 5; Tasks 8 then 9 after 7; Task 11 after 10.** 4. Finish with `dotnet build ZB.MOM.WW.ScadaBridge.slnx && dotnet test ZB.MOM.WW.ScadaBridge.slnx` — expect all 30 test projects green (the known environmental exceptions: the 8 Playwright E2E that need a live docker cluster, and the pre-existing `SiteConnectionUpGauge_*` gRPC gauge flake). ## Findings Coverage | Report finding (08-conventions-tests-underdeveloped.md, round 2) | Severity | Coverage | |---|---|---| | NF1 — live production Inbound API key in `test.txt` at the repo root (plus, found on triage: live AD/SQL credentials in the gitignored `ldap_login.txt`/`sql_login.txt`/`sqllogin.txt`) | High | Tasks 1 (rotate via the shipped dual-key flow, `needs-user`), 2 (delete the credential files, `needs-user`), 3 (`.gitignore` + pre-commit guard) | | NF2 — failover envelope unmeasured; `FailoverTimingTests` skip precondition already satisfied | Medium | **Owned by PLAN-R2-01** (rig wiring / measurement). This plan: Task 10 step 3 repairs the register row whose "PLAN-01 rig landing" trigger fired unnoticed, repointing it at PLAN-R2-01's measurement task | | NF3 — CHANGELOG.md abandoned and factually wrong (`Operator`/`Engineer` claim false since M7) | Medium | Task 4 | | NF4 — options-validation residue: 6 AuditLog sub-options, Host Node/Database/Logging, unbound `OperationTrackingOptions` | Low | Tasks 5, 6 (AuditLog site + central sub-options), 7 (Host options; empty `NodeName` fails fast), 8 (OperationTracking binding + the discovered missing site `IOperationTrackingStore` registration) | | NF5 — deferral tracking re-scattered into three places (tracker registry, drifted root `deferred.md`, stale Fix-now rows) | Low | Tasks 10 (fold SBR gap + wonder-app overlay into the register, prune Fix-now, fix fired trigger, subordinate the tracker), 11 (delete root `deferred.md`, `needs-user`) | | NF6 — stale `EntitySerializer.cs:553-554` comment contradicts the shipped area export | Low | Task 12 | | NF7 — untracked residue: MES draft plan doc, root generated reports, `.claire/` | Low | Task 13 (commit the plan doc, remove the reports `needs-user`); Task 3 gitignores `.claire/`; `test.txt` itself is NF1/Task 2 | | NF8 — duplicate `DataConnectionOptions`/`CommunicationOptions`/`NotificationOptions` bindings in the Host (and, verified at plan time: the owning SCEs bind section names that exist in NO appsettings — the Host duplicates were the live bindings) | Low | Task 9 (canonicalize SCE section names to `ScadaBridge:*`, then delete the Host duplicates, with a binding-test lock) |