# wonder-app-vd03 upgrade: `b8b4c18f` → master `5fb4b4db` > **Status: EXECUTED 2026-11-12** — vd03 runs `5fb4b4db`. The readiness notes (§a–§g) were written against master > `363f0d81`; nothing between that and `5fb4b4db` changed the schema, configuration or preflight. The execution > record is §(h); the owner-action table and "Not determined" list below carry the outcomes. **All six owner actions > are closed and nothing is left undetermined (close-out 2026-11-12, §h last bullet).** > > **Superseded on vd03 the same day:** vd03 was upgraded again, `5fb4b4db` → `fea7de0a` (ServiceLevel 255 → 250), as the > DEV rehearsal for the QA pair. Record: [`qa-upgrade-b8b4c18f-to-fea7de0a.md`](qa-upgrade-b8b4c18f-to-fea7de0a.md). Upgrade-readiness notes for the DEV box wonder-app-vd03. The box runs the `OtOpcUaHost` service from `E:\ApiInstall\OtOpcUa`: one fused admin+driver node with one FOCAS driver (Makino Z-34184). The ConfigDb is `OtOpcUaConfig` on the box's SQL instance (`docs/deployments/wonder-app-vd03-makino-z-34184.md`). Tracking issue: Gitea #793. **Baseline.** The deployed build is `b8b4c18f` (2026-09-03), not the `e82881c1` recorded in the 2026-08-11 notes. The evidence: - All 409 source documents in six deployed PDBs match `b8b4c18f`. - `deps.json` pins `LocalDb 0.3.4` and `MxGateway.Client 0.2.1`, which only `b8b4c18f` used. Everything below is the `b8b4c18f..master` delta. The ConfigDb schema is the same at both commits. The last migration at both is `20260731162342_AddApiKeyTable`. > **Executed 2026-11-12 (master `5fb4b4db`), owner-authorised WinRM for this upgrade.** See §(h) for the record. > The general wonder-box remote-exec hold in the root `CLAUDE.md` is unchanged. ## Owner actions, in order | # | Action | Blocks | Source | Outcome (2026-11-12) | |---|---|---|---|---| | 1 | Run `scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql` against `OtOpcUaConfig`, then fix every `attention` + `blocking = YES` row. | The first deploy on master | §g | **Done.** Every blocking check `ok` (run with `sqlcmd -I`, see §g). | | 2 | **Add `Security__ApiKeys__Pepper`** to the service environment (at least 16 characters, or a `${secret:}` reference). The box does not have this setting. | **Host start**: the host exits with code 1 | §b | **Done.** Generated on the box; service environment 25 → 26 variables. | | 3 | Check that `Security__Jwt__SigningKey` is at least 32 UTF-8 bytes. | Host start | §b | **Done.** 59 bytes; Issuer/Audience set. | | 4 | Apply the `EnableSnapshotIsolation` migration to `OtOpcUaConfig`. | Every deploy (SQL error 3952) | §a | **Done.** `snapshot=ON`; post-deploy deploy had no 3952. | | 5 | Check the 22 FOCAS tags for the `MaxRpm` → `MotorSpeedPct` and `AA_1/AA_2` → `AA6/AA7` renames, and for `String` on MACRO/PMC/PARAM addresses. Tell ScadaBridge about the FOCAS value changes. | Client-visible | §d, check 13 | **Done.** The one rename (`Spindle/S1/MaxRpm`, TAG-000b5c2fdf3f) was re-pointed and renamed `MotorSpeedPct` (address `FIXED:Spindle/S1/MotorSpeedPct`, Float64 / Double) and shipped in deployment `9adc5590` (11:06 UTC, Applied); it reads Good. Nine value-change rows. **ScadaBridge: not applicable on vd03** — its only data connection there is `MxGateway` (`localhost:5120`), with no OPC UA connection to OtOpcUa; the consumer-impact list lives in scadaproj `sister-project-history/otopcua.md` for when ScadaBridge binds OtOpcUa (QA is still on `b8b4c18f`). | | 6 | Make the #782 go/hold decision for SDK 2.0.1. The rollout runbook still marks it **HOLD**. | Policy | §c | **Done.** Owner go for vd03, recorded in #782 (2026-11-12). The runbook's general HOLD for other hosts is unchanged. | ## (a) ConfigDb migration - **One migration is in range:** `20261022102459_EnableSnapshotIsolation`, from commit `73e74e27`. - It runs `ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON` with `suppressTransaction: true`. - It changes no tables. `Down` is deliberately a no-op. - Source: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.cs`. - **Why it is required.** `DraftSnapshotFactory.FromConfigDbAsync` now reads the whole draft inside one `IsolationLevel.Snapshot` transaction. Without the option, the first read fails with SQL error 3952 and the deploy is rejected. Source: the class remarks in `Configuration/Validation/DraftSnapshotFactory.cs`. - **The migration is not applied automatically.** The host never migrates the ConfigDb: `Program.cs:129` only migrates the secrets store. docker-dev runs a one-shot `migrator` container because "production owns schema changes" (`docker-dev/README.md`, `docker-dev/docker-compose.yml`). The owner applies it by hand, using either of these: - **Option 1:** `dotnet ef database update 20261022102459_EnableSnapshotIsolation` from `src/Core/ZB.MOM.WW.OtOpcUa.Configuration`, with `OTOPCUA_CONFIG_CONNECTION` pointing at `OtOpcUaConfig` (`DesignTimeDbContextFactory`, `docs/Configuration.md`). - **Option 2:** run this idempotent script in SSMS. It is the output of `dotnet ef migrations script 20260731162342_AddApiKeyTable 20261022102459_EnableSnapshotIsolation --idempotent`: ```sql IF NOT EXISTS (SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20261022102459_EnableSnapshotIsolation') BEGIN ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON; END; GO IF NOT EXISTS (SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20261022102459_EnableSnapshotIsolation') BEGIN INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) VALUES (N'20261022102459_EnableSnapshotIsolation', N'10.0.7'); END; GO ``` - **Login and timing.** The login needs `ALTER` on the database. Turning the option on waits for in-flight transactions but does not need exclusive access, so it can run before the binary swap while the old build is still up. **The old build is unaffected by it:** it uses no `IsolationLevel` and `ALLOW_SNAPSHOT_ISOLATION` does not change default reads. A rollback needs no schema undo. ## (b) Configuration: environment variables and appsettings **The 23 service environment variables against master** (names only; values were not inspected): | Variable | On master | |---|---| | `ASPNETCORE_URLS`, `Cluster__Hostname`, `Cluster__Port`, `Cluster__PublicHostname`, `Cluster__SeedNodes__0`, `ConnectionStrings__ConfigDb`, `DOTNET_ENVIRONMENT`, `LocalDb__Path`, `Security__Auth__DisableLogin`, `Security__DeployApiKey` | Unchanged. `Security:DeployApiKey` now also gates the new `GET /api/deployments/{id}` (`AdminUI/Api/DeployApiEndpoints.cs`). | | `Cluster__Roles__0/1` + `OTOPCUA_ROLES` | Unchanged (`EffectiveRoles` is already in the baseline). `Cluster:Roles` wins. If the two sets disagree, master logs a Warning. If a role were a `cluster-…` role, #678.6 (`f292cf3b`) now still loads `appsettings.admin-driver.json` (`docs/Configuration.md`). | | `Secrets__SqlitePath` | Unchanged. The Secrets 0.6.2 absolute, outside-content-root path rule (`b037cc7a`) is **already in the baseline**, and the box boots with it. | | `Security__Jwt__SigningKey/Issuer/Audience` | **Re-interpreted: now validated at start** (`f72af76e`, `3ddb6876`; `Security/Jwt/JwtOptions.Validate`). When a key is set, it must be at least 32 UTF-8 bytes, and Issuer/Audience must be non-blank, or the host does not start. Bearer validation is now the first-party `AddJwtBearer`, pinned to HS256. | | `ServerHistorian__Enabled/Endpoint/ApiKey/UseTls` | Same keys. The HistorianGateway client goes 0.3.0 → 1.0.0 (`230cef4e`, historiangw#131). On `UseTls=false`, the cert options are ignored, not refused. A partially valid `CaCertificatePath` bundle now fails client creation. **Not determined:** the oldest gateway build that master works with. If `Enabled=true` on the box, check the box's gateway version first. | | `GALAXY_MXGW_API_KEY` | Not read by any code directly. A Galaxy config that references `env:GALAXY_MXGW_API_KEY` is **in scope** for #815: `GALAXY_MXGW_` is an allowed Galaxy prefix and `API_KEY` matches `[A-Z0-9_]`. It is also **allowed** under #835/#849 as a shared name, so the deploy gate does not refuse it. It would only collide with a Galaxy driver whose id or name has the env form `API` or `API_KEY` (`DriverCredentialPolicy.cs`, the `GalaxyScope` definition and the comment at line 374). The box has no Galaxy driver. The 2026-08-11 notes say this key was already rejected by the gateway. | | `Authentication__Ldap__DevStubMode` | **Dead at both commits.** No code reads an `Authentication:` section; the live key is `Security:Ldap:DevStubMode`, which the deployed `appsettings.admin-driver.json` sets to `false`, with `Transport=Ldaps`. Harmless. | **New settings master needs that are not in that list:** - **`Security__ApiKeys__Pepper`: REQUIRED. It is missing on the box.** - `ApiKeyOptionsValidator` (`f72af76e`, `Host/Configuration/ApiKeyOptionsValidator.cs`) fails host start on any admin or driver node when the pepper is blank or shorter than 16 characters. - The box has no pepper in its environment and none in the deployed `appsettings*.json`. Without it the upgraded service exits during start-up. Because that happens before Serilog starts, the reason only reaches stderr and the Event Log. - On `b8b4c18f` an empty pepper already made minting refuse and verification reject every key (`ApiKeySecret.cs`, `ApiKeyOpcUaUserAuthenticator.cs`). So no working data-plane API keys exist today, and choosing a fresh pepper invalidates nothing. Check 14 counts the `ApiKey` rows. - Supply it as a `${secret:}` reference or an environment variable. Never commit it. - Everything else is optional and has defaults: - `OpcUa:CredentialLockout:*` (#833/#844/#845; UserName/API-key only, never Anonymous). - `Security:ApiKeys` miss budgets, `StoreTimeoutMs` and `StoreCacheSeconds`. - `DriverWrites:Timeout` (10 s, #777). - `ServerHistorian:EventReadHorizon` (`00:00:00` = off, #694). - `ServerHistorian:Gateway:*`. - `Scripting:Runner`: the default stays `InProcess`. #793 says not to enable `Shadow` or `OutOfProcess` on the wonder boxes yet. - Out-of-range values for these log a Warning or fail start only when they are set (`docs/Configuration.md`, `docs/security.md`). **Deployed appsettings vs master.** The deployed files were diffed against the `b8b4c18f` and `master` copies, with secrets redacted. - `appsettings.json`: - `ContinuousHistorization` still carries the pre-b8b4c18f values (`Capacity 0`, `DrainBatchSize 64`; no `DrainConcurrency`, `MaxWriteAttempts` or `CaptureWhilePrimaryUnreachable`). Master's in-code defaults fill the absent keys, and the box's explicit values win. This only matters if continuous historization is enabled. - `DriverWrites` and `EventReadHorizon` are absent, so the defaults apply. - The box uses `Secrets:MasterKey` `FilePath` instead of the shipped `EnvVarName`. That is a site choice and is unchanged. - `appsettings.driver.json`: master lowers `Opc.Ua` from Debug to Information. The fused node loads the `admin-driver` overlay instead, which is unchanged. - **Keep the box's own `appsettings*.json` across the swap.** Do not take the publisher's copies. **Other host-level changes:** - The publish output now contains `script-worker/` (about 97 MB). It is unused while the runner is `InProcess` (`docs/ServiceHosting.md` §Script worker processes). - `/health/ready` gains three checks: - OPC UA endpoint started; - script runner (at worst Degraded); - deploy coordinator (at worst Degraded). Source: `Host/Health/HealthEndpoints.cs`. - No new ports are opened. ## (c) PKI and OPC UA SDK 2.0.1 (#782) - **SDK version.** It moves from 1.5.378.106 to 2.0.1 (`Directory.Packages.props`), passing through 1.5.378.182 at `2da58d03`. Namespace URIs and NodeIds are unchanged. - **Certificates and policies.** The PKI store is still the Directory store under `OpcUa:PkiStoreRoot` (default `pki`, beside the exe). The existing application certificate is reused with the same thumbprint and private key. Security profiles are unchanged: `None`, `Basic256Sha256Sign` and `Basic256Sha256SignAndEncrypt`. Sources: `docs/security.md`, `docs/plans/2026-11-10-opcua-sdk-2-upgrade.md`. - **CA KeyUsage.** #793 says CA certificates must carry the `keyCertSign`/`cRLSign` KeyUsage, or SDK 1.5.378.182+ rejects them. The upgrade plan (line 159) lists it as a preflight check. **Before the swap, check every CA in `pki\issuer` and `pki\trusted`, and ScadaBridge's trust of this server's chain.** A self-signed application certificate is not a CA and is not affected. The repo states no further KeyUsage rule. - **Release status.** `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` still marks the release **HOLD**. Its open gates are the memory soak (#799), Windows service validation (#800) and live consumer testing (#801). vd03 would be the first Windows host to run SDK 2. Record the go/hold decision in #782. - **ScadaBridge.** The adapter passed its 18 tests on `None` + API key. SignAndEncrypt from ScadaBridge is still untested because ScadaBridge has no client certificate yet (ScadaBridge#121; the issue predates this upgrade). ## (d) What ScadaBridge will see (b8b4c18f..master) The detail is in `../scadaproj/.claude/skills/sister-project-history/otopcua.md`; the commits are cited below. **On this FOCAS box:** - **Macros.** Macros are now decoded at the right offset (`665ecd55`), so `parts-count`/`parts-required` (`MACRO:3901`/`3902`) change magnitude. A vacant macro reads `BadNoData`. A fractional value on an integer tag reads `BadTypeMismatch`. - **FixedTree.** - Positions are scaled by their envelope (`69e0a622`; for example 2801574 → 280.1574). - Servo/spindle `Load` is now a real percent, and spindle Load is Float64 (`1dba46bd`). - `Spindle/*/MaxRpm` is renamed `MotorSpeedPct` (`1dba46bd`). - `Axes/AA_1`/`AA_2` are renamed `AA6`/`AA7` (`9460da51`). - Unreported program fields read `BadNoData` instead of a Good 0 (`26b512ad`). - A CNC refusal makes only that tag Bad and no longer Degrades the driver (`2724d7da`, #740). - `String` on a PMC/PARAM/MACRO address is refused, and the tag is skipped (`cc140593`). - New nodes: a `Status/` subtree (#257) and optional `ALARM:` conditions (#647). - **ServiceLevel** drops to 0 if the OPC UA endpoint fails to start (#762). - **Virtual tags.** A disabled virtual tag reads `BadOutOfService` (#787). VT→VT reads now produce values (#791). - **Logins and API keys.** - Credential lockout now covers UserName/API-key logins only, never Anonymous (#833/#844/#845). - Unknown API keys are rate-limited per client (#751). - Expired or disabled keys are refused for writes and acks (#563). - **ACL.** Event and subscription filtering by ACL changes only if `Security:Acl:Enabled` is on (#586/#588, #797/#808, #826). Whether it is on for vd03 was not determined. - **Address-space changes.** Surgically added raw nodes are now announced with model-change events (#599). Native condition state survives a rebuild (#641), and the native alarm feed survives a deploy (#662). - **Alarms and history.** Each method transition emits exactly one event (#587). Overload deferral is per condition (#838/#854/#855). Event history now carries the event type and condition name (#746). Processed reads with no historian return `BadAggregateNotSupported` (#682). **Not reachable here (no such driver):** - Galaxy: OPC DA quality grading of status rows (#866, `363f0d81`), first-value race (#859), acks wait for confirmation (#869). - MTConnect: blank string reads Good `""` (#824). - OpcUaClient: host-name check (#816). - Heidenhain: new driver (#803). - AbCip: the native transport is the default. ## (e) Install, swap and rollback The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Rollback). `docs/ServiceHosting.md` §Install/Uninstall describes a fresh install only. 1. **Publish** a fresh directory: `dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj -c Release -r win-x64 --self-contained true -o `. Hash it into a manifest stored outside the directory, then copy it to the box over SMB. 2. **Back up** these: - the whole install, with the service stopped: `robocopy E:\ApiInstall\OtOpcUa /E /COPYALL /DCOPY:DAT`. This covers `pki\`, `appsettings*.json`, `data\` (LocalDb) and `logs\`; - the secrets SQLite file at `Secrets__SqlitePath` (it is outside the install), and its master-key file; - `reg export HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaHost`. **It holds secrets, so protect the file;** - `BACKUP DATABASE OtOpcUaConfig`. 3. **Add the pepper** (action 2) and check the JWT key length (action 3) on the service key. Apply the migration (§a). 4. **Swap the directories:** - `Stop-Service OtOpcUaHost` and wait for Stopped. - `Move-Item` the live install to a fresh sibling `$PreviousInstall`, then `Move-Item` the candidate into `E:\ApiInstall\OtOpcUa`. - Copy back the box's `appsettings*.json`, `pki\` and `data\`. - `Start-Service` and wait for Running. 5. **Never run `Install-Services.ps1`, `Uninstall-Services.ps1` or `Refresh-Services.ps1` here.** - `sc delete` wipes the 23 environment variables. - `Install-Services.ps1` rewrites the `Environment` value with only 2–3 entries. - `Refresh-Services.ps1` assumes NSSM and `C:\publish`. - The folder swap leaves the registry key untouched. 6. **Rollback.** - Stop the service and move the candidate to a quarantine path. - Restore `$PreviousInstall` (or the robocopy backup), including its `appsettings*.json`, `pki\` and `data\`. - Check the certificate thumbprint, then start the service. - Clients reconnect. Do not replay writes or acks. No schema undo is needed (§a). Remove the pepper only if it got in the way; `b8b4c18f` ignores it. The baseline already reads gzip artifacts (`9dbd5e96` predates `b8b4c18f`), so artifacts written by master stay readable after a rollback. **Not determined:** whether the `b8b4c18f` build reads a LocalDb or alarm-outbox file after master has written to it. Restore `data\` from the backup. ## (f) Post-deploy checks - **Boot log** `logs\otopcua-YYYYMMDD.log`. Read its contents; the listed size shows 0 while the service holds it open. - Expect `OtOpcUa.Host starting with roles=[admin,driver]`, with no roles-disagreement warning. - About 15 s later expect `Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced.` This comes from `CredentialReferencePreflightService` in `AdminUI/Deployments/CredentialReferencePreflight.cs`. - If the service stops immediately, check the Windows Event Log and stderr for the pepper/JWT `FATAL:` message (`Program.cs`, #618). - **Health**, at `http://wonder-app-vd03.zmr.zimmer.com:9000`: - `/healthz`: process up. - `/health/ready`: ConfigDb, Akka Up, OPC UA endpoint started, script runner, deploy coordinator. - `/health/active`. All three should return 200 on this single node (`Host/Health/HealthEndpoints.cs`, `docs/ServiceHosting.md` §Health endpoints). - **`/raw` credential banner (#834, warn-only by owner decision).** It should be absent. FOCAS has no credential fields (`docs/security.md` §"Credential references are namespaced per driver"). A banner listing rows means a stored, deployed or running out-of-namespace reference that the next deploy will refuse. - **`/hosts`.** The FOCAS driver row should be Healthy, with the per-node chips (#847/#851). Galaxy host statuses do not apply here (no Galaxy driver). - **Deploy once from the AdminUI**, then check `/fleet` and `/deployments`. - Expect `Applied`, not `Applied (degraded)` (#601, `docs/Redundancy.md`). Node rows show `Applying` until the address-space outcome is reported (#868). - A 3952 error means §a was skipped. - A `VirtualTagNoEffectiveTrigger`, `CredentialReferenceOutOfScope` or similar rejection means a preflight row was missed. - **OPC UA** at `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa`: - The server certificate thumbprint is unchanged (`A873DF51…`) and ServiceLevel is non-zero. - Browse the raw and UNS trees and read `parts-count` with the Client CLI (`docs/Client.CLI.md`). Expect the new magnitude. - From ScadaBridge: it reconnects, values are Good, and any `MaxRpm`/`AA_x`/`Load` bindings have been re-pointed. ## (g) Preflight SQL: `scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql` **What it is.** The script is read-only: it uses SELECT plus table variables. It is written against the `b8b4c18f` schema and needs SQL Server 2016+ at compatibility level 130 or higher. **How to run it:** `sqlcmd -S -d OtOpcUaConfig -E -I -i vd03-preupgrade-b8b4c18f-configdb-checks.sql -W -s "|" -o preflight.txt` **`-I` is required.** `sqlcmd` starts with `QUOTED_IDENTIFIER OFF`, and the script's XML-method inserts then fail with Msg 1934 after check 2 (found on the 2026-11-12 run). **Output.** - Each check produces a header row (`ok` / `attention` / `info`, the row count, whether it blocks the deploy, and its source), followed by its detail rows. - Credential values are never printed: reference names are shown, and literals are redacted. | # | Check | Blocks deploy? | Enforced on master by | Source | |---|---|---|---|---| | 0 | Environment: database, compatibility level, last migration, snapshot-isolation state | — | — | — | | 1 | `VirtualTagNoEffectiveTrigger` | yes | `DraftValidator.ValidateVirtualTagTriggers` | #783 `d9b95dcf` | | 2 | `CalculationNoEffectiveTrigger` | yes | `DraftValidator.ValidateCalculationTags` rule 3 | `4d3fc9c8` | | 3 | Scripts that read a VirtualTag: they now resolve and start producing values | no (behaviour) | `VirtualTagHostActor` via dependency mux | #791 `000d7f55` | | 4 | `VirtualTagDependencyCycle` (approximate: bounded search, 25 hops) | yes | `DraftValidator.ValidateVirtualTagDependencyCycles` | #791 `000d7f55` | | 5 | Newly forbidden script APIs (#793 list plus E2's `System.Threading`, `Type` reflection, `RuntimeHelpers`) | no (compile failure at runtime → node Bad) | `Core.Scripting/ForbiddenTypeAnalyzer.cs` | #792 `0e96dd08`, E2 `2e916031` | | 6 | `CredentialReferenceOutOfScope`, plus the #836 `UserCertificatePath` rule | yes | `CredentialReferenceDeployChecks`, `DriverCredentialPolicy` | #815 `a224fd63`, #836 `63331b04`, #850 `3dbfe6ec` | | 7 | `CredentialReferenceOtherInstance` (`secret:` segment / `env:` part names another same-type driver) | yes | `DriverCredentialPolicy.DescribeOtherInstanceProblems` | #835 `63331b04`, #849 `4a6229e4`/`d68dff09` | | 8 | Reserved driver names (`drv`, `DRV-<12 hex>`) | no (save/rename refused) | `DriverCredentialPolicy.IsReservedInstanceName` | #848/#849 `d08e59ee`/`d68dff09` | | 9 | `MTConnectMultipleDevices` | yes | `DraftValidator.ValidateMTConnectSingleDevice` | #628 `dcc827ca` | | 10 | Sql: `SqlQueryNotDeclared` (10a), the `write` block (10b, review), writable mismatch (10c, warning) | 10a/10b yes | `SqlTagDeployChecks` | #781 `241318e7`, `f5c30ed0` | | 11 | Heidenhain drivers present (expected none; the type is new) | yes | `HeidenhainDeployChecks` | #803 `2b45694f` | | 12 | UNS effective-name collisions (an unchanged rule, re-checked) | yes | `DraftValidator.ValidateUnsEffectiveLeafUniqueness` | — | | 13 | FOCAS renames, `String` refusals and value changes | no (deploy warning; client-visible) | `FocasTagDefinitionFactory`, FOCAS wire client | `1dba46bd`, `9460da51`, `cc140593`, `69e0a622`, `665ecd55`, `26b512ad` | | 14 | Inventory: drivers, tags, VTs, scripts, API keys | — | — | — | **Approximations.** T-SQL cannot reproduce the C# regexes or `System.Text.Json` exactly; the script header lists every approximation. In short: - `ctx.GetTag("…")` literals are extracted after whitespace normalisation. - Cycles are found by a bounded path search. - `ISJSON` is stricter than `System.Text.Json` (comments, trailing commas). - The #850 merged driver+device path check is shown as `review`. - Check 5 is a text search, so its `review` patterns can be false positives (for example `JsonElement.GetProperty`). Some branches cannot occur under the database's own CHECK constraints but are kept for completeness. For example, `CK` on `VirtualTag` makes `ChangeTriggered = 0` without a timer impossible, so only the "no literal read" branch of check 1 can fire. **Not covered.** `EquipmentTagConfigInspector` findings are warnings only (`Deployment:TagConfigValidationMode=Warn` by default, `AdminOperationsActor`). Cluster topology, ACL scope and `EquipReferenceUnresolved` are unchanged since the baseline. **How it was verified (2026-11-11):** - A throwaway database `otopcua_vd03preflight_` was built on the local docker-dev SQL Server 2022 (`localhost,14330`) at compatibility level 130. It was migrated with `dotnet ef database update 20260731162342_AddApiKeyTable`, which is the `b8b4c18f` schema. - It was seeded with one or more rows that trigger each check. Every check fired as expected, and check 11 was the expected `ok`. The database was then dropped. - The script also ran clean against the docker-dev `OtOpcUa` database (master schema, 53 drivers). Every gate check was `ok` there, which matches master's own gate accepting that config. ## Not determined Resolved during the 2026-11-12 run (§h): - ~~`ServerHistorian__Enabled`~~: enabled, pointing at `http://localhost:5221` — HistorianGateway `ea9f21c` (Contracts/Client 1.0.0, deployed 2026-11-11). - ~~JWT signing key length~~: 59 bytes. - ~~#782 go/hold~~: owner go for vd03, recorded in #782. - ~~`Security:Acl:Enabled`~~: **off.** Neither `appsettings.json` nor `appsettings.admin-driver.json` has a `Security:Acl` section (default `false`), and the 05:56 boot log has no `node ACL enforcement is ENABLED` warning, which `OtOpcUaServerHostedService` logs whenever it is on. - ~~ScadaBridge's security mode against vd03~~: **not applicable.** ScadaBridge's management API on vd03 lists one data connection, `MxGateway` (`http://localhost:5120`); it has no OPC UA connection to this server. - ~~Rollback reopen of `data\`~~: **compatible.** LocalDb stays at `0.3.4` at both commits; master's only LocalDb schema change is the additive `alarm_condition_comment` table (plus a move of the scripted-alarm comment trail out of `alarm_condition_state.comments_json`). A copy of vd03's `otopcua-localdb.db` taken over SMB after the upgrade has 0 rows in `alarm_condition_state` and `alarm_condition_comment`, so a rollback loses nothing. The FasterLog outbox is not in use (`ContinuousHistorization:Enabled=false`), and neither is the alarm store-and-forward (`AlarmHistorian:Enabled=false`). The rollback still restores `data\` from the backup. ## (h) Execution record, 2026-11-12 (`b8b4c18f` → `5fb4b4db`) Run over WinRM (owner-authorised for this upgrade) with staged scripts; the build was a fresh `dotnet publish -r win-x64 --self-contained`, zip SHA-256 `D9B88959…4DD6`, 1396 files, staged at `E:\ApiInstall\_stage-otopcua-5fb4b4db`. - **Preflight.** The ConfigDb is on the app box's own instance (`WONDER-APP-VD03`, SQL 16.0.4245, compat 160), not on wonder-sql-vd03. Every blocking check was `ok`. Check 13: one rename (`Spindle/S1/MaxRpm`, TAG-000b5c2fdf3f) and nine value-change rows. The tag was left as authored; on master it reads `BadNodeIdUnknown` until it is re-pointed to `MotorSpeedPct`. Inventory: 1 FOCAS driver, 22 raw tags, 2 UNS references, 0 API keys. - **Box facts.** JWT signing key 59 bytes, Issuer/Audience set. `ServerHistorian` enabled → `http://localhost:5221` (HistorianGateway `ea9f21c`, deployed 2026-11-11). Box clock agrees with the Mac within seconds (w32tm source USWAP-VDC-ZMR1); ScadaBridge runs on the same box, so #873 skew does not apply to it. PKI: only the self-signed `A873DF51…` (not a CA); no `issuer\certs`. - **Phase 1 (service up).** Registry export and `BACKUP DATABASE … COPY_ONLY` (default backup directory, `OtOpcUaConfig-pre-20261112-otopcua-5fb4b4db.bak`); snapshot-isolation migration applied (`snapshot=ON`, last migration `20261022102459_EnableSnapshotIsolation`); `Security__ApiKeys__Pepper` generated on the box (48 random bytes, base64, never left the box) and added to the service environment (25 → 26 variables). - **Phase 2.** Stop (1 s), robocopy of `E:\ApiInstall\OtOpcUa-data` to `E:\ApiInstall\OtOpcUa-backups\pre-20261112-otopcua-5fb4b4db`, folder swap (previous install at `E:\ApiInstall\OtOpcUa.bak.20261112-pre-5fb4b4db`), box `appsettings*`, `pki\`, `logs\` carried, start. **Downtime about 11 s.** Rollback script: `_stage-otopcua-5fb4b4db\rollback.ps1`. - **Verified.** `/healthz`, `/health/ready` (opcua-endpoint, script-runner, configdb, config-publish, akka) and `/health/active` all 200. Boot log: `roles=[admin,driver]` with no disagreement warning, and the #815 preflight line "every stored and deployed reference is namespaced". OPC UA: ServiceLevel 255; raw and UNS `parts-count` Good; `Program/Name` carries the live program path. - **New log lines.** SDK 2 retries LDS `RegisterServer`/`RegisterServer2` against its own endpoint (`BadServiceUnsupported`, backing off); this is visible now because #874 routes SDK warnings to Serilog (#876). The mesh-PSK refresh warns that `ot-mesh-psk-dev` is absent; the box runs the default DPS mesh transport, so the PSK is not used. - **Post-deploy deploy (owner-authorised, 11:02 UTC).** `/deployments` "Deploy current configuration" → deployment `e4b4824a` (rev `475db34b…`) Sealed; chip back to "In sync"; `/fleet` 1 node **Applied** (not degraded), no failure reason; no 3952. The drift had been master's composer re-serialising the 2026-08-05 configuration (0 `ConfigEdit` rows since that seal). Afterwards: ServiceLevel 255, `parts-count` and `Program/Name` Good, `/health/ready` 200. - **Close-out (2026-11-12, after the deploy).** Done without WinRM: SMB reads of the box's appsettings, boot log and a LocalDb copy; ScadaBridge's management API; the AdminUI; and the Client CLI. The `MaxRpm` tag had already been re-pointed in the AdminUI and deployed as `9adc5590` (11:06:43 UTC, `/fleet` Applied, chip "In sync"). Client CLI reads: `…/Spindle/S1/MotorSpeedPct` Good (0, spindle idle), `…/Spindle/S1/Load` Good, `…/Spindle/S1/MaxRpm` `BadNodeIdUnknown` as expected, and the UNS `parts-count`/`parts-required` still present. ## Worker isolation and box-wide denies (lmxopcua#891, 2026-11-12, owner-requested) The vd03 layout differs from QA. The install root is `E:\ApiInstall\OtOpcUa`, with no `app\` folder, and data lives in `E:\ApiInstall\OtOpcUa-data`. Copies containing secrets are spread across `E:\ApiInstall`: env/registry backups, `kek.key` files, and old `pki\own\private` copies in about a dozen `OtOpcUa.bak*` folders. So the deny covers **all of `E:\ApiInstall`** instead of listing each app folder. **Installer:** `E:\psrem-stage\otopcua-891-f47891ff\install`, master `f47891ff`. SHA256: `.ps1` `e0c11aea…41a3`, `.psm1` `f9425e4c…dfde`. ```powershell .\Install-ScriptWorkerIsolation.ps1 -InstallRoot E:\ApiInstall\OtOpcUa -HostServiceAccount LocalSystem ` -DenyPaths E:\ApiInstall,E:\ApiInstall\OtOpcUa\pki,E:\ApiInstall\OtOpcUa-data,C:\ProgramData\ZB\kek,E:\psrem-stage ``` - **Order:** `-WhatIf`, then apply (12 steps changed), then a re-apply (12 steps unchanged). - **What the record holds:** the folders in `-DenyPaths` (the installer drops the install root, because `InstallRootDeny` already covers it), the PE cache, worker read+execute on `script-worker\`, and the outbound firewall block. - **`script-worker\` stays usable:** its explicit `(RX)` comes before the deny inherited from `E:\ApiInstall`. - **`-ConfigureHostService` deliberately left out.** The vd03 runner is still `InProcess` (the default), so the host service environment is unchanged and no restart was needed. When the vd03 runner flip is scheduled, re-run with the same `-DenyPaths` **plus** `-ConfigureHostService`, then set `Scripting__Runner` and restart. **Backups locked down:** 39 files under `E:\ApiInstall` and `E:\psrem-stage` now have a protected DACL that allows only SYSTEM and Administrators: `*.reg`, `*envbak*.txt` and `*envnames*.txt`. That covers OtOpcUa, HG, mxgw and the ScadaBridge deploy backups. None were deleted. **Installer warnings checked:** - The locked backups have protected DACLs by design. - `kek.key` in `HistorianGateway\data`, `MxGateway\data`, `OtOpcUa-data` and `ScadaBridge` allows only SYSTEM (Read) and Administrators. None of them grants anything to Everyone, Users or RESTRICTED. **After:** `/health/ready` returns 200 for OtOpcUa `:9000`, Overview `:5320`, ScadaBridge `:8085`, mxgw `:5130` and HG `:5220`. **Rollback:** `-Uninstall`, which removes the recorded denies, the anchor service, the firewall rule and the PE cache ACLs. To undo the lockdown on a backup file, run `icacls /reset`.