diff --git a/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md b/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md
new file mode 100644
index 000000000..596224410
--- /dev/null
+++ b/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md
@@ -0,0 +1,321 @@
+# wonder-app-vd03 upgrade: `b8b4c18f` → master `363f0d81`
+
+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`.
+
+> ⛔ The remote-exec hold on the wonder boxes still applies (root `CLAUDE.md`): no psbridge and no WinRM. The
+> owner runs every step on the box. SMB copies and direct SQL logins are allowed.
+
+## Owner actions, in order
+
+| # | Action | Blocks | Source |
+|---|---|---|---|
+| 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 |
+| 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 |
+| 3 | Check that `Security__Jwt__SigningKey` is at least 32 UTF-8 bytes. | Host start | §b |
+| 4 | Apply the `EnableSnapshotIsolation` migration to `OtOpcUaConfig`. | Every deploy (SQL error 3952) | §a |
+| 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 |
+| 6 | Make the #782 go/hold decision for SDK 2.0.1. The rollout runbook still marks it **HOLD**. | Policy | §c |
+
+## (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 vd03-preupgrade-b8b4c18f-configdb-checks.sql -W -s "|" -o preflight.txt`
+
+**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
+
+- Whether the box's `ServerHistorian__Enabled` is true, and if so, which HistorianGateway build it points at
+ (client 1.0.0 compatibility).
+- Whether the box's JWT signing key is at least 32 bytes.
+- Whether `Security:Acl:Enabled` is on.
+- ScadaBridge's security mode against vd03.
+- Whether `b8b4c18f` can reopen LocalDb or outbox files after master has written them; restore them from backup
+ on rollback.
+- The current #782 go/hold state. The repo runbook says HOLD.
diff --git a/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql b/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql
new file mode 100644
index 000000000..ff572fef9
--- /dev/null
+++ b/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql
@@ -0,0 +1,680 @@
+/* =====================================================================================================
+ vd03-preupgrade-b8b4c18f-configdb-checks.sql
+
+ Pre-upgrade ConfigDb checks for wonder-app-vd03: OtOpcUa b8b4c18f (2026-09-03, the build actually deployed —
+ identified by PDB source-hash + deps.json package versions) -> master 363f0d81.
+ Gitea lmxopcua#793. Companion notes: docs/deploy/vd03-upgrade-b8b4c18f-to-master.md
+
+ STRICTLY READ-ONLY. SELECT statements only, plus INSERTs into local TABLE VARIABLES (session-scoped, never
+ written to this database). No DDL, no DML against any database table, no temp tables, no transactions.
+
+ Run it against the ConfigDb while the box is STILL on b8b4c18f, i.e. before the EnableSnapshotIsolation
+ migration and before the new binaries start:
+
+ sqlcmd -S WONDER-APP-VD03 -d OtOpcUaConfig -E -i vd03-preupgrade-b8b4c18f-configdb-checks.sql -W -s "|" -o preflight.txt
+ (or open it in SSMS with the OtOpcUaConfig database selected and run it; use "Results to Text" for one file)
+
+ The database name on vd03 is OtOpcUaConfig (docs/deployments/wonder-app-vd03-makino-z-34184.md); the
+ script does not hard-code it (no USE), so select the right database first. Check 0 prints which one you hit.
+
+ Requirements: SQL Server 2016+ AND database compatibility level >= 130 (OPENJSON). Check 0 reports both.
+
+ Schema: written against the b8b4c18f EF model (last migration 20260731162342_AddApiKeyTable — identical to
+ e82881c1; no migration landed between them). master adds only 20261022102459_EnableSnapshotIsolation (no
+ table/column change), so the script also runs unchanged after the upgrade. Every rule checked here is new in
+ b8b4c18f..master (DraftValidator / ForbiddenTypeAnalyzer were untouched between e82881c1 and b8b4c18f).
+
+ OUTPUT: every check emits two result sets
+ (1) a one-row header: check id, title, status ('ok' | 'attention' | 'info'), row count, blocking?, source
+ (2) the detail rows (empty when ok).
+ 'attention' + blocking = YES means master's deploy gate (DraftValidator / AdminOperationsActor) will REFUSE the
+ next deploy until fixed. blocking = NO means a behaviour change or an authoring/runtime effect, not a refusal.
+
+ Secrets: no credential VALUE is ever selected. Credential fields are reported as a classification plus the
+ reference NAME (secret:/env: names are not secrets); literals print as ''.
+
+ Approximations (the C# rules use Roslyn-free regexes and System.Text.Json; T-SQL cannot reproduce them
+ exactly — every approximation errs toward listing MORE rows, never fewer, except where noted):
+ * ctx.GetTag("...") literal extraction normalises whitespace first (CR/LF/TAB -> space, runs collapsed,
+ spaces around '.', '(' and the quotes removed). A path literal containing a double space would be
+ mangled; a GetTag inside a comment or string is counted (the C# regex counts it too).
+ * Cycle detection (check 4) is a bounded path search (depth <= 25) over the same graph DraftValidator
+ builds; it can miss a cycle longer than 25 hops and reports each cycle once per VirtualTag member.
+ * JSON: ISJSON/OPENJSON reject comments and trailing commas that System.Text.Json (with the policy's
+ options) accepts, so such a blob shows as 'config is not valid JSON' here but may pass the gate.
+ * The #850 merged driver+device check is approximated (see check 6).
+ ===================================================================================================== */
+
+SET NOCOUNT ON;
+
+/* ---------------------------------------------------------------------------------------------
+ Shared working sets (table variables)
+ --------------------------------------------------------------------------------------------- */
+
+DECLARE @Num TABLE (n int PRIMARY KEY);
+WITH x AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM x WHERE n < 256)
+INSERT @Num (n) SELECT n FROM x OPTION (MAXRECURSION 256);
+
+-- Scripts with a whitespace-normalised copy of the source for ctx.GetTag("...") extraction.
+DECLARE @Script TABLE (ScriptId nvarchar(64) PRIMARY KEY, Name nvarchar(128), Src nvarchar(max) COLLATE Latin1_General_BIN2);
+INSERT @Script (ScriptId, Name, Src)
+SELECT s.ScriptId, s.Name,
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(s.SourceCode, CHAR(13), ' '), CHAR(10), ' '), CHAR(9), ' ')
+ , ' ', ' '), ' ', ' '), ' ', ' '), ' ', ' '), ' ', ' ')
+ , ' .', '.'), '. ', '.'), 'GetTag (', 'GetTag('), 'GetTag( ', 'GetTag('), '" )', '")'), ' )', ')'), '( "', '("'), '( ', '(')
+FROM dbo.Script s
+WHERE s.ScriptId IS NOT NULL;
+
+-- Literal ctx.GetTag("[") reads (EquipmentScriptPaths.ExtractDependencyRefs: ctx\s*\.\s*GetTag\s*\(\s*"([^"]+)"\s*\)).
+DECLARE @Ref TABLE (ScriptId nvarchar(64), Ref nvarchar(4000) COLLATE Latin1_General_BIN2);
+WITH occ AS (
+ SELECT s.ScriptId, CHARINDEX(N'ctx.GetTag("', s.Src) AS p
+ FROM @Script s WHERE CHARINDEX(N'ctx.GetTag("', s.Src) > 0
+ UNION ALL
+ SELECT o.ScriptId, CHARINDEX(N'ctx.GetTag("', s.Src, o.p + 1)
+ FROM occ o INNER JOIN @Script s ON s.ScriptId = o.ScriptId
+ WHERE CHARINDEX(N'ctx.GetTag("', s.Src, o.p + 1) > 0
+)
+INSERT @Ref (ScriptId, Ref)
+SELECT o.ScriptId, SUBSTRING(s.Src, o.p + 12, e.q - (o.p + 12))
+FROM occ o
+INNER JOIN @Script s ON s.ScriptId = o.ScriptId
+CROSS APPLY (SELECT CHARINDEX(N'"', s.Src, o.p + 12) AS q) e
+WHERE e.q > o.p + 12 AND SUBSTRING(s.Src, e.q + 1, 1) = N')'
+OPTION (MAXRECURSION 0);
+
+-- RawPath of every raw tag: //// (RawPathResolver).
+DECLARE @FolderPath TABLE (RawFolderId nvarchar(64) PRIMARY KEY, Path nvarchar(4000));
+WITH f AS (
+ SELECT RawFolderId, CAST(Name AS nvarchar(4000)) AS Path, 0 AS d FROM dbo.RawFolder WHERE ParentRawFolderId IS NULL AND RawFolderId IS NOT NULL
+ UNION ALL
+ SELECT c.RawFolderId, CAST(f.Path + N'/' + c.Name AS nvarchar(4000)), f.d + 1
+ FROM dbo.RawFolder c INNER JOIN f ON c.ParentRawFolderId = f.RawFolderId WHERE f.d < 256
+)
+INSERT @FolderPath SELECT RawFolderId, MIN(Path) FROM f GROUP BY RawFolderId OPTION (MAXRECURSION 300);
+
+DECLARE @GroupPath TABLE (TagGroupId nvarchar(64) PRIMARY KEY, Path nvarchar(4000));
+WITH g AS (
+ SELECT TagGroupId, CAST(Name AS nvarchar(4000)) AS Path, 0 AS d FROM dbo.TagGroup WHERE ParentTagGroupId IS NULL AND TagGroupId IS NOT NULL
+ UNION ALL
+ SELECT c.TagGroupId, CAST(g.Path + N'/' + c.Name AS nvarchar(4000)), g.d + 1
+ FROM dbo.TagGroup c INNER JOIN g ON c.ParentTagGroupId = g.TagGroupId WHERE g.d < 256
+)
+INSERT @GroupPath SELECT TagGroupId, MIN(Path) FROM g GROUP BY TagGroupId OPTION (MAXRECURSION 300);
+
+DECLARE @RawTag TABLE (
+ TagId nvarchar(64), Name nvarchar(128),
+ DeviceId nvarchar(64), DriverInstanceId nvarchar(64),
+ DriverName nvarchar(128), DriverType nvarchar(32) COLLATE Latin1_General_BIN2,
+ AccessLevel nvarchar(16), DataType nvarchar(32), TagConfig nvarchar(max), RawPath nvarchar(4000) COLLATE Latin1_General_BIN2);
+INSERT @RawTag
+SELECT t.TagId, t.Name, t.DeviceId, di.DriverInstanceId, di.Name, di.DriverType, t.AccessLevel, t.DataType, t.TagConfig,
+ CASE WHEN di.RawFolderId IS NOT NULL AND fp.Path IS NULL THEN NULL -- unresolvable folder: no RawPath
+ WHEN t.TagGroupId IS NOT NULL AND gp.Path IS NULL THEN NULL -- unresolvable group: no RawPath
+ ELSE ISNULL(fp.Path + N'/', N'') + di.Name + N'/' + dv.Name + N'/' + ISNULL(gp.Path + N'/', N'') + t.Name END
+FROM dbo.Tag t
+INNER JOIN dbo.Device dv ON dv.DeviceId = t.DeviceId
+INNER JOIN dbo.DriverInstance di ON di.DriverInstanceId = dv.DriverInstanceId
+LEFT JOIN @FolderPath fp ON fp.RawFolderId = di.RawFolderId
+LEFT JOIN @GroupPath gp ON gp.TagGroupId = t.TagGroupId;
+
+-- Calculation tags: RawPath + scriptId (TagConfig.scriptId, string) + trigger fields.
+DECLARE @Calc TABLE (TagId nvarchar(64), RawPath nvarchar(4000) COLLATE Latin1_General_BIN2, ScriptId nvarchar(4000),
+ ChangeTriggered bit, TimerIntervalMs bigint NULL);
+INSERT @Calc
+SELECT r.TagId, r.RawPath,
+ (SELECT TOP 1 j.[value] FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) j WHERE j.[key] COLLATE Latin1_General_BIN2 = N'scriptId' AND j.[type] = 1),
+ ISNULL((SELECT TOP 1 CASE j.[value] WHEN N'false' THEN 0 ELSE 1 END FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) j
+ WHERE j.[key] COLLATE Latin1_General_BIN2 = N'changeTriggered' AND j.[type] = 3), 1),
+ (SELECT TOP 1 TRY_CAST(j.[value] AS bigint) FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) j
+ WHERE j.[key] COLLATE Latin1_General_BIN2 = N'timerIntervalMs' AND j.[type] = 2)
+FROM @RawTag r WHERE r.DriverType = N'Calculation';
+
+-- UNS reference effective names -> backing RawPath (EquipmentReferenceMap: DisplayNameOverride else raw tag Name).
+DECLARE @EquipRef TABLE (EquipmentId nvarchar(64) COLLATE Latin1_General_BIN2, EffName nvarchar(128) COLLATE Latin1_General_BIN2, RawPath nvarchar(4000) COLLATE Latin1_General_BIN2);
+INSERT @EquipRef
+SELECT u.EquipmentId, CASE WHEN ISNULL(u.DisplayNameOverride, N'') = N'' THEN r.Name ELSE u.DisplayNameOverride END, r.RawPath
+FROM dbo.UnsTagReference u INNER JOIN @RawTag r ON r.TagId = u.TagId;
+
+-- Enabled VirtualTags and their mux key {EquipmentId}/{Name}.
+DECLARE @VT TABLE (VirtualTagId nvarchar(64), EquipmentId nvarchar(64) COLLATE Latin1_General_BIN2, Name nvarchar(128) COLLATE Latin1_General_BIN2,
+ VtKey nvarchar(300) COLLATE Latin1_General_BIN2, ScriptId nvarchar(64),
+ ChangeTriggered bit, TimerIntervalMs int NULL, Enabled bit);
+INSERT @VT SELECT v.VirtualTagId, v.EquipmentId, v.Name, v.EquipmentId + N'/' + v.Name, v.ScriptId, v.ChangeTriggered, v.TimerIntervalMs, v.Enabled
+FROM dbo.VirtualTag v;
+
+-- Which entity uses which script (for reporting).
+DECLARE @ScriptUse TABLE (ScriptId nvarchar(64), UsedByKind nvarchar(20), UsedById nvarchar(64), UsedByEnabled bit, EquipmentId nvarchar(64) NULL);
+INSERT @ScriptUse SELECT ScriptId, N'VirtualTag', VirtualTagId, Enabled, EquipmentId FROM @VT;
+INSERT @ScriptUse SELECT a.PredicateScriptId, N'ScriptedAlarm', a.ScriptedAlarmId, a.Enabled, a.EquipmentId FROM dbo.ScriptedAlarm a;
+INSERT @ScriptUse SELECT c.ScriptId, N'CalculationTag', c.TagId, 1, NULL FROM @Calc c WHERE c.ScriptId IS NOT NULL;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 0 — environment
+ --------------------------------------------------------------------------------------------- */
+DECLARE @lastMig nvarchar(150) = (SELECT TOP 1 MigrationId FROM dbo.__EFMigrationsHistory ORDER BY MigrationId DESC);
+DECLARE @compat int = (SELECT compatibility_level FROM sys.databases WHERE database_id = DB_ID());
+DECLARE @snap nvarchar(60) = (SELECT snapshot_isolation_state_desc FROM sys.databases WHERE database_id = DB_ID());
+SELECT N'0' AS check_id, N'Environment' AS title,
+ CASE WHEN @lastMig = N'20260731162342_AddApiKeyTable' AND @compat >= 130 THEN N'ok' ELSE N'attention' END AS status,
+ 1 AS row_count, N'NO' AS blocking,
+ N'expects last migration 20260731162342_AddApiKeyTable (b8b4c18f) and compat >= 130' AS source;
+SELECT DB_NAME() AS database_name, @@SERVERNAME AS server_name, CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(40)) AS sql_version,
+ @compat AS compatibility_level, @lastMig AS last_applied_migration, @snap AS snapshot_isolation_state,
+ N'master needs migration 20261022102459_EnableSnapshotIsolation (ALLOW_SNAPSHOT_ISOLATION ON) before the first deploy' AS note;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 1 — #783 VirtualTagNoEffectiveTrigger (BLOCKING)
+ master: DraftValidator.ValidateVirtualTagTriggers (d9b95dcf). Enabled VT, TimerIntervalMs null/<=0, and either
+ ChangeTriggered = 0, or ChangeTriggered = 1 with a resolvable script that has NO literal ctx.GetTag read.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c1 TABLE (VirtualTagId nvarchar(64), EquipmentId nvarchar(64), Name nvarchar(128), ScriptId nvarchar(64), ChangeTriggered bit, TimerIntervalMs int NULL, literal_reads int, reason nvarchar(200));
+INSERT @c1
+SELECT v.VirtualTagId, v.EquipmentId, v.Name, v.ScriptId, v.ChangeTriggered, v.TimerIntervalMs,
+ (SELECT COUNT(*) FROM @Ref r WHERE r.ScriptId = v.ScriptId),
+ CASE WHEN v.ChangeTriggered = 0 THEN N'ChangeTriggered=0 and no TimerIntervalMs'
+ ELSE N'change-triggered but script has no literal ctx.GetTag read, and no TimerIntervalMs' END
+FROM @VT v
+WHERE v.Enabled = 1 AND ISNULL(v.TimerIntervalMs, 0) <= 0
+ AND (v.ChangeTriggered = 0
+ OR (EXISTS (SELECT 1 FROM @Script s WHERE s.ScriptId = v.ScriptId) AND NOT EXISTS (SELECT 1 FROM @Ref r WHERE r.ScriptId = v.ScriptId)));
+SELECT N'1' AS check_id, N'#783 VirtualTagNoEffectiveTrigger' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateVirtualTagTriggers; commit d9b95dcf' AS source FROM @c1;
+SELECT * FROM @c1 ORDER BY EquipmentId, Name;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 2 — CalculationNoEffectiveTrigger (BLOCKING)
+ master: DraftValidator.ValidateCalculationTags rule 3 (4d3fc9c8, E4/R07:U5). Calc tag with no timerIntervalMs > 0
+ and either changeTriggered=false, or change-triggered with a resolvable script that has no literal ctx.GetTag read.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c2 TABLE (TagId nvarchar(64), RawPath nvarchar(4000), ScriptId nvarchar(4000), changeTriggered bit, timerIntervalMs bigint NULL, reason nvarchar(200));
+INSERT @c2
+SELECT c.TagId, c.RawPath, c.ScriptId, c.ChangeTriggered, c.TimerIntervalMs,
+ CASE WHEN c.ChangeTriggered = 0 THEN N'changeTriggered=false and no timerIntervalMs'
+ ELSE N'change-triggered but script has no literal ctx.GetTag read, and no timerIntervalMs' END
+FROM @Calc c
+WHERE ISNULL(c.TimerIntervalMs, 0) <= 0
+ AND (c.ChangeTriggered = 0
+ OR (EXISTS (SELECT 1 FROM @Script s WHERE s.ScriptId = c.ScriptId) AND NOT EXISTS (SELECT 1 FROM @Ref r WHERE r.ScriptId = c.ScriptId)));
+SELECT N'2' AS check_id, N'CalculationNoEffectiveTrigger' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateCalculationTags (rule 3); commit 4d3fc9c8' AS source FROM @c2;
+SELECT * FROM @c2 ORDER BY RawPath;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 3 — #791 scripts that read a VirtualTag (BEHAVIOUR CHANGE, not blocking)
+ master routes VirtualTag outputs through the dependency mux (000d7f55), so a ctx.GetTag("/")
+ read now RESOLVES and starts producing values / firing where it produced none before. A bare VT name (no '/')
+ never resolved and still does not; it is listed so it can be fixed. {{equip}}/ resolves references only —
+ naming a VT there is EquipReferenceUnresolved (pre-existing error; master's message now hints the key form).
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c3 TABLE (ScriptId nvarchar(64), ScriptName nvarchar(128), used_by nvarchar(max), read_literal nvarchar(4000), match_kind nvarchar(80), target_vt nvarchar(64), target_enabled bit);
+INSERT @c3
+SELECT s.ScriptId, s.Name,
+ STUFF((SELECT N', ' + u.UsedByKind + N':' + u.UsedById + CASE WHEN u.UsedByEnabled = 0 THEN N'(disabled)' ELSE N'' END
+ FROM @ScriptUse u WHERE u.ScriptId = s.ScriptId FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N''),
+ r.Ref,
+ CASE WHEN r.Ref = v.VtKey THEN N'VT key {EquipmentId}/{Name}: now resolves (#791)'
+ ELSE N'bare VT name: never resolved, still does not' END,
+ v.VirtualTagId, v.Enabled
+FROM @Ref r
+INNER JOIN @Script s ON s.ScriptId = r.ScriptId
+INNER JOIN @VT v ON r.Ref = v.VtKey OR (r.Ref = v.Name AND CHARINDEX(N'/', r.Ref) = 0);
+SELECT N'3' AS check_id, N'#791 scripts reading a VirtualTag (VT->VT now works)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'NO' AS blocking, N'VirtualTag outputs via dependency mux; commit 000d7f55' AS source FROM @c3;
+SELECT * FROM @c3 ORDER BY ScriptId, read_literal;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 4 — #791 VirtualTagDependencyCycle (BLOCKING) — APPROXIMATE
+ master: DraftValidator.ValidateVirtualTagDependencyCycles (000d7f55). Nodes = enabled VT keys + every Calculation
+ tag RawPath; edges = literal ctx.GetTag reads that are themselves nodes (VT reads after {{equip}}/
+ substitution through the equipment's references). Cycles made only of Calculation tags are the pre-existing
+ CalculationDependencyCycle rule and are not listed. Bounded search (25 hops).
+ --------------------------------------------------------------------------------------------- */
+DECLARE @Node TABLE (NodeKey nvarchar(4000) COLLATE Latin1_General_BIN2, IsVt bit);
+INSERT @Node SELECT VtKey, 1 FROM @VT WHERE Enabled = 1;
+INSERT @Node SELECT RawPath, 0 FROM @Calc WHERE RawPath IS NOT NULL AND RawPath NOT IN (SELECT NodeKey FROM @Node);
+
+DECLARE @Edge TABLE (FromKey nvarchar(4000) COLLATE Latin1_General_BIN2, ToKey nvarchar(4000) COLLATE Latin1_General_BIN2);
+-- VT edges ({{equip}}/ substituted to the backing RawPath when the equipment has that reference).
+INSERT @Edge
+SELECT DISTINCT v.VtKey, COALESCE(er.RawPath, r.Ref)
+FROM @VT v
+INNER JOIN @Ref r ON r.ScriptId = v.ScriptId
+LEFT JOIN @EquipRef er ON er.EquipmentId = v.EquipmentId AND LEFT(r.Ref, 10) = N'{{equip}}/' AND er.EffName = SUBSTRING(r.Ref, 11, 4000)
+WHERE v.Enabled = 1 AND COALESCE(er.RawPath, r.Ref) IN (SELECT NodeKey FROM @Node);
+-- Calculation edges (no {{equip}} substitution for calc scripts).
+INSERT @Edge
+SELECT DISTINCT c.RawPath, r.Ref
+FROM @Calc c INNER JOIN @Ref r ON r.ScriptId = c.ScriptId
+WHERE c.RawPath IS NOT NULL AND r.Ref IN (SELECT NodeKey FROM @Node);
+
+DECLARE @c4 TABLE (start_vt_key nvarchar(4000), cycle_path nvarchar(max), hops int);
+WITH walk AS (
+ SELECT e.FromKey AS StartKey, e.ToKey AS CurKey, CAST(e.FromKey + N' -> ' + e.ToKey AS nvarchar(max)) AS Path,
+ CAST(N'|' + e.FromKey + N'|' AS nvarchar(max)) AS Seen, 1 AS hops
+ FROM @Edge e INNER JOIN @Node n ON n.NodeKey = e.FromKey AND n.IsVt = 1
+ UNION ALL
+ SELECT w.StartKey, e.ToKey, w.Path + N' -> ' + e.ToKey, w.Seen + w.CurKey + N'|', w.hops + 1
+ FROM walk w INNER JOIN @Edge e ON e.FromKey = w.CurKey
+ WHERE w.hops < 25 AND w.CurKey <> w.StartKey AND CHARINDEX(N'|' + w.CurKey + N'|', w.Seen) = 0
+)
+INSERT @c4 SELECT StartKey, Path, hops FROM walk WHERE CurKey = StartKey OPTION (MAXRECURSION 30);
+SELECT N'4' AS check_id, N'#791 VirtualTagDependencyCycle (approximate)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateVirtualTagDependencyCycles; commit 000d7f55' AS source FROM @c4;
+SELECT * FROM @c4 ORDER BY start_vt_key;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 5 — #792 + E2 newly forbidden script APIs (RUNTIME compile failure, not a deploy refusal)
+ master: Core.Scripting/ForbiddenTypeAnalyzer.cs (0e96dd08 #792; 2e916031 E2). ScriptEvaluator throws
+ ScriptSandboxViolationException at compile, so the VT / Calculation tag / scripted alarm never evaluates (and the
+ /scripts editor shows the diagnostic). Text search over the raw source — case-sensitive, may over-report
+ (e.g. a word in a comment); patterns flagged 'review' are known to collide with legitimate APIs.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @Pat TABLE (pattern nvarchar(100) COLLATE Latin1_General_BIN2, denied nvarchar(200), source nvarchar(40), certainty nvarchar(10));
+INSERT @Pat VALUES
+ (N'%AppContext%', N'System.AppContext', N'#792 0e96dd08', N'likely'),
+ (N'%Console.%', N'System.Console', N'#792 0e96dd08', N'likely'),
+ (N'%GCSettings%', N'System.Runtime.GCSettings', N'#792 0e96dd08', N'likely'),
+ (N'%ProfileOptimization%', N'System.Runtime.ProfileOptimization', N'#792 0e96dd08', N'likely'),
+ (N'%EncodingProvider%', N'System.Text.(CodePages)EncodingProvider', N'#792 0e96dd08', N'likely'),
+ (N'%TypeDescriptor%', N'System.ComponentModel.TypeDescriptor', N'#792 0e96dd08', N'likely'),
+ (N'%CryptoConfig%', N'System.Security.Cryptography.CryptoConfig', N'#792 0e96dd08', N'likely'),
+ (N'%CurrentCulture%', N'CultureInfo.CurrentCulture / DefaultThreadCurrentCulture', N'#792 0e96dd08', N'likely'),
+ (N'%CurrentUICulture%', N'CultureInfo.CurrentUICulture / DefaultThreadCurrentUICulture', N'#792 0e96dd08', N'likely'),
+ (N'%ClearCachedData%', N'TimeZoneInfo.ClearCachedData', N'#792 0e96dd08', N'likely'),
+ (N'%RuntimeHelpers%', N'System.Runtime.CompilerServices.RuntimeHelpers', N'E2 2e916031', N'likely'),
+ (N'%System.Threading%', N'System.Threading namespace (all but CancellationToken/Interlocked/Volatile)', N'E2 2e916031', N'likely'),
+ (N'%SemaphoreSlim%', N'System.Threading.SemaphoreSlim', N'E2 2e916031', N'likely'),
+ (N'%Monitor.%', N'System.Threading.Monitor', N'E2 2e916031', N'review'),
+ (N'%SpinWait%', N'System.Threading.SpinWait', N'E2 2e916031', N'likely'),
+ (N'%SpinLock%', N'System.Threading.SpinLock', N'E2 2e916031', N'likely'),
+ (N'%ResetEvent%', N'System.Threading.Manual/AutoResetEvent(Slim)', N'E2 2e916031', N'likely'),
+ (N'%Mutex%', N'System.Threading.Mutex', N'E2 2e916031', N'likely'),
+ (N'%WaitHandle%', N'System.Threading.(Event)WaitHandle', N'E2 2e916031', N'likely'),
+ (N'%CancellationTokenSource%', N'System.Threading.CancellationTokenSource', N'E2 2e916031', N'likely'),
+ (N'%ReaderWriterLock%', N'System.Threading.ReaderWriterLock(Slim)', N'E2 2e916031', N'likely'),
+ (N'%ThreadLocal%', N'System.Threading.ThreadLocal', N'E2 2e916031', N'likely'),
+ (N'%AsyncLocal%', N'System.Threading.AsyncLocal', N'E2 2e916031', N'likely'),
+ (N'%CountdownEvent%', N'System.Threading.CountdownEvent', N'E2 2e916031', N'likely'),
+ (N'%Barrier%', N'System.Threading.Barrier', N'E2 2e916031', N'review'),
+ (N'%Thread.%', N'System.Threading.Thread (already denied pre-E2)', N'E2 2e916031', N'review'),
+ (N'%Type.GetType(%', N'System.Type.GetType (reflection entry point)', N'E2 2e916031', N'likely'),
+ (N'%.InvokeMember(%', N'System.Type.InvokeMember', N'E2 2e916031', N'likely'),
+ (N'%.GetMethod%(%', N'System.Type.GetMethod(s)', N'E2 2e916031', N'review'),
+ (N'%.GetPropert%(%', N'System.Type.GetProperty/GetProperties (JsonElement.GetProperty is FINE)', N'E2 2e916031', N'review'),
+ (N'%.GetField%(%', N'System.Type.GetField(s)', N'E2 2e916031', N'review'),
+ (N'%.GetMember%(%', N'System.Type.GetMember(s)', N'E2 2e916031', N'review'),
+ (N'%.GetConstructor%(%', N'System.Type.GetConstructor(s)', N'E2 2e916031', N'review'),
+ (N'%.GetEvent%(%', N'System.Type.GetEvent(s)', N'E2 2e916031', N'review'),
+ (N'%.GetDefaultMembers(%', N'System.Type.GetDefaultMembers', N'E2 2e916031', N'likely');
+
+DECLARE @c5 TABLE (ScriptId nvarchar(64), ScriptName nvarchar(128), used_by nvarchar(max), denied_api nvarchar(200), certainty nvarchar(10), source nvarchar(40));
+INSERT @c5
+SELECT s.ScriptId, s.Name,
+ STUFF((SELECT N', ' + u.UsedByKind + N':' + u.UsedById + CASE WHEN u.UsedByEnabled = 0 THEN N'(disabled)' ELSE N'' END
+ FROM @ScriptUse u WHERE u.ScriptId = s.ScriptId FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N''),
+ p.denied, p.certainty, p.source
+FROM dbo.Script s
+INNER JOIN @Pat p ON s.SourceCode COLLATE Latin1_General_BIN2 LIKE p.pattern
+WHERE s.ScriptId IS NOT NULL;
+SELECT N'5' AS check_id, N'#792/E2 newly forbidden script APIs' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'NO (fails at compile/run)' AS blocking, N'ForbiddenTypeAnalyzer; commits 0e96dd08, 2e916031' AS source FROM @c5;
+SELECT * FROM @c5 ORDER BY certainty, ScriptId, denied_api;
+
+/* ---------------------------------------------------------------------------------------------
+ Credential-field walk shared by checks 6-7 (#815/#835/#849/#836).
+ Policy (Core.Abstractions/DriverCredentialPolicy.cs + CredentialReferences.cs), fields matched at ANY depth,
+ key compared case-insensitively, in DriverConfig AND every DeviceConfig of the driver:
+ GalaxyMxGateway: ApiKeySecretRef (literal allowed), FallbackPasswordSecretRef (literal only as dev:...)
+ secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... | env:GALAXY_MXGW_...
+ OpcUaClient: Password, UserCertificatePassword (literal allowed); path field UserCertificatePath (#836)
+ secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...
+ Heidenhain: InspectPassword, DncPassword, DataPassword (literal refused) — type did not exist at b8b4c18f.
+ ${secret:name} is the same as secret:name. file: is always refused. A prefix in another casing is refused.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @Field TABLE (DriverType nvarchar(32), FieldName nvarchar(64), LiteralPolicy nvarchar(16));
+INSERT @Field VALUES
+ (N'GalaxyMxGateway', N'ApiKeySecretRef', N'Allowed'), (N'GalaxyMxGateway', N'FallbackPasswordSecretRef', N'DevPrefixOnly'),
+ (N'OpcUaClient', N'Password', N'Allowed'), (N'OpcUaClient', N'UserCertificatePassword', N'Allowed'),
+ (N'Heidenhain', N'InspectPassword', N'Refused'), (N'Heidenhain', N'DncPassword', N'Refused'), (N'Heidenhain', N'DataPassword', N'Refused');
+DECLARE @Ns TABLE (DriverType nvarchar(32), Kind nvarchar(10), Prefix nvarchar(64) COLLATE Latin1_General_BIN2);
+INSERT @Ns VALUES
+ (N'GalaxyMxGateway', N'secret', N'galaxy/'), (N'GalaxyMxGateway', N'secret', N'otopcua/galaxy/'),
+ (N'GalaxyMxGateway', N'env', N'OTOPCUA_GALAXY_'), (N'GalaxyMxGateway', N'env', N'GALAXY_MXGW_'),
+ (N'OpcUaClient', N'secret', N'opcuaclient/'), (N'OpcUaClient', N'env', N'OTOPCUA_OPCUACLIENT_'),
+ (N'Heidenhain', N'secret', N'heidenhain/'), (N'Heidenhain', N'env', N'OTOPCUA_HEIDENHAIN_');
+
+-- Credential-bearing config blobs: DriverConfig + each DeviceConfig.
+DECLARE @Blob TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerId nvarchar(64), OwnerName nvarchar(128), Config nvarchar(max));
+INSERT @Blob
+SELECT d.DriverInstanceId, d.Name, ft.DriverType, N'DriverConfig', d.DriverInstanceId, d.Name, d.DriverConfig
+FROM dbo.DriverInstance d INNER JOIN (SELECT DISTINCT DriverType FROM @Field) ft ON ft.DriverType = d.DriverType -- table is case-insensitive like the C# policy
+UNION ALL
+SELECT d.DriverInstanceId, d.Name, ft.DriverType, N'DeviceConfig', v.DeviceId, v.Name, v.DeviceConfig
+FROM dbo.DriverInstance d INNER JOIN (SELECT DISTINCT DriverType FROM @Field) ft ON ft.DriverType = d.DriverType
+INNER JOIN dbo.Device v ON v.DriverInstanceId = d.DriverInstanceId;
+
+-- Recursive JSON walk: every property at every depth, with its parent object (for the #836 AuthType switch).
+DECLARE @Prop TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerId nvarchar(64), OwnerName nvarchar(128),
+ FieldPath nvarchar(4000), PropKey nvarchar(4000), PropValue nvarchar(max) COLLATE Latin1_General_BIN2, PropType tinyint, ParentJson nvarchar(max));
+WITH j AS (
+ SELECT b.DriverInstanceId, b.DriverName, b.DriverType, b.BlobKind, b.OwnerId, b.OwnerName,
+ CAST(o.[key] AS nvarchar(4000)) AS FieldPath, CAST(o.[key] AS nvarchar(4000)) AS PropKey, o.[value] AS PropValue, CAST(o.[type] AS tinyint) AS PropType,
+ CAST(b.Config AS nvarchar(max)) AS ParentJson, 0 AS depth, CAST(0 AS bit) AS InArray
+ FROM @Blob b CROSS APPLY OPENJSON(CASE WHEN ISJSON(b.Config) = 1 THEN b.Config END) o
+ UNION ALL
+ SELECT j.DriverInstanceId, j.DriverName, j.DriverType, j.BlobKind, j.OwnerId, j.OwnerName,
+ CAST(CASE WHEN j.PropType = 4 THEN j.FieldPath + N'[' + c.[key] + N']' ELSE j.FieldPath + N'.' + c.[key] END AS nvarchar(4000)),
+ CAST(c.[key] AS nvarchar(4000)), c.[value], CAST(c.[type] AS tinyint), CAST(j.PropValue AS nvarchar(max)), j.depth + 1,
+ CAST(CASE WHEN j.PropType = 4 THEN 1 ELSE 0 END AS bit)
+ FROM j CROSS APPLY OPENJSON(j.PropValue) c
+ WHERE j.PropType IN (4, 5) AND j.depth < 32
+)
+INSERT @Prop SELECT DriverInstanceId, DriverName, DriverType, BlobKind, OwnerId, OwnerName, FieldPath, PropKey, PropValue, PropType, ParentJson
+FROM j WHERE InArray = 0 OPTION (MAXRECURSION 40);
+
+-- Classify every credential field value (CredentialReferences.Parse + IsAcceptable).
+DECLARE @Cred TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerId nvarchar(64), OwnerName nvarchar(128),
+ FieldPath nvarchar(4000), LiteralPolicy nvarchar(16), Kind nvarchar(80), RefName nvarchar(4000) COLLATE Latin1_General_BIN2, Acceptable bit, Shown nvarchar(4000));
+INSERT @Cred
+SELECT p.DriverInstanceId, p.DriverName, p.DriverType, p.BlobKind, p.OwnerId, p.OwnerName, p.FieldPath, f.LiteralPolicy, k.Kind, k.RefName,
+ CASE WHEN k.Kind IN (N'blank', N'secret', N'env') THEN 1
+ WHEN k.Kind = N'literal' AND f.LiteralPolicy = N'Allowed' THEN 1
+ WHEN k.Kind = N'literal' AND f.LiteralPolicy = N'DevPrefixOnly' AND LOWER(LEFT(p.PropValue, 4)) = N'dev:' THEN 1
+ ELSE 0 END,
+ CASE WHEN k.Kind IN (N'literal') THEN N'' WHEN k.Kind = N'non-string' THEN N''
+ WHEN LOWER(LEFT(p.PropValue, 4)) = N'dev:' THEN N'dev:' ELSE p.PropValue END
+FROM @Prop p
+INNER JOIN @Field f ON f.DriverType = p.DriverType AND LOWER(f.FieldName) = LOWER(p.PropKey)
+CROSS APPLY (
+ SELECT
+ CASE WHEN p.PropType = 0 OR (p.PropType = 1 AND p.PropValue = N'') THEN N'blank'
+ WHEN p.PropType <> 1 THEN N'non-string'
+ WHEN (LOWER(LEFT(p.PropValue, 9)) = N'${secret:' AND LEFT(p.PropValue, 9) <> N'${secret:')
+ OR (LOWER(LEFT(p.PropValue, 7)) = N'secret:' AND LEFT(p.PropValue, 7) <> N'secret:')
+ OR (LOWER(LEFT(p.PropValue, 4)) = N'env:' AND LEFT(p.PropValue, 4) <> N'env:') THEN N'out-of-scope (prefix casing)'
+ WHEN LEFT(p.PropValue, 9) = N'${secret:' AND RIGHT(p.PropValue, 1) <> N'}' THEN N'out-of-scope (malformed ${secret:})'
+ WHEN LEFT(p.PropValue, 9) = N'${secret:' OR LEFT(p.PropValue, 7) = N'secret:' THEN N'secret?'
+ WHEN LEFT(p.PropValue, 4) = N'env:' THEN N'env?'
+ WHEN LOWER(LEFT(p.PropValue, 5)) = N'file:' THEN N'out-of-scope (file:)'
+ ELSE N'literal' END AS RawKind,
+ CASE WHEN LEFT(p.PropValue, 9) = N'${secret:' AND RIGHT(p.PropValue, 1) = N'}' THEN SUBSTRING(p.PropValue, 10, LEN(p.PropValue) - 10)
+ WHEN LEFT(p.PropValue, 7) = N'secret:' THEN SUBSTRING(p.PropValue, 8, 4000)
+ WHEN LEFT(p.PropValue, 4) = N'env:' THEN SUBSTRING(p.PropValue, 5, 4000) END AS Name
+) rk
+CROSS APPLY (
+ SELECT
+ CASE WHEN rk.RawKind = N'secret?' THEN
+ CASE WHEN EXISTS (SELECT 1 FROM @Ns n WHERE n.DriverType = p.DriverType AND n.Kind = N'secret'
+ AND LEFT(rk.Name, LEN(n.Prefix)) = n.Prefix
+ AND LEN(rk.Name) - LEN(n.Prefix) BETWEEN 1 AND 200
+ AND SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) NOT LIKE N'%[^-A-Za-z0-9._/]%'
+ AND N'/' + SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) + N'/' NOT LIKE N'%//%'
+ AND N'/' + SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) + N'/' NOT LIKE N'%/./%'
+ AND N'/' + SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) + N'/' NOT LIKE N'%/../%')
+ THEN N'secret' ELSE N'out-of-scope (secret: outside namespace)' END
+ WHEN rk.RawKind = N'env?' THEN
+ CASE WHEN EXISTS (SELECT 1 FROM @Ns n WHERE n.DriverType = p.DriverType AND n.Kind = N'env'
+ AND LEFT(rk.Name, LEN(n.Prefix)) = n.Prefix
+ AND LEN(rk.Name) - LEN(n.Prefix) BETWEEN 1 AND 100
+ AND SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) NOT LIKE N'%[^A-Z0-9_]%')
+ THEN N'env' ELSE N'out-of-scope (env: outside prefix)' END
+ ELSE rk.RawKind END AS Kind,
+ rk.Name AS RefName
+) k;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 6 — #815 CredentialReferenceOutOfScope (+ #836 UserCertificatePath) (BLOCKING)
+ master: Configuration/Validation/CredentialReferenceDeployChecks.cs (a224fd63 #815; 63331b04 #836; 3dbfe6ec #850).
+ A RUNNING driver keeps resolving an old reference warn-only (LegacyCredentialReferenceTracker), but the NEXT deploy
+ is refused until the field is migrated. Same rule the AdminUI /raw banner and CredentialReferencePreflightService
+ report (#834). Also lists config blobs that are not valid JSON (the gate reports those too).
+ UserCertificatePath: must be blank or a relative path of [A-Za-z0-9._-] segments inside
+ Drivers:OpcUaClient:UserCertificateDirectory; only checked where AuthType in the SAME object is absent/null/
+ 'Certificate'/2 — rows where AuthType sits in the other blob are 'review' (the gate checks the merged config, #850).
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c6 TABLE (DriverType nvarchar(32), DriverName nvarchar(128), DriverInstanceId nvarchar(64), blob nvarchar(30), owner_name nvarchar(128), field_path nvarchar(4000),
+ finding nvarchar(200), value_shown nvarchar(4000), severity nvarchar(10));
+INSERT @c6
+SELECT c.DriverType, c.DriverName, c.DriverInstanceId, c.BlobKind, c.OwnerName, c.FieldPath,
+ CASE WHEN c.Kind = N'literal' AND c.LiteralPolicy = N'DevPrefixOnly' THEN N'literal refused: must be secret:/env: in namespace or explicit dev:VALUE'
+ WHEN c.Kind = N'literal' THEN N'literal refused for this field'
+ ELSE c.Kind END,
+ c.Shown, N'blocking'
+FROM @Cred c WHERE c.Acceptable = 0;
+INSERT @c6
+SELECT b.DriverType, b.DriverName, b.DriverInstanceId, b.BlobKind, b.OwnerName, N'(whole blob)', N'config is not valid JSON (credential fields cannot be checked) — verify; comments/trailing commas may be a false positive', NULL, N'blocking'
+FROM @Blob b WHERE ISJSON(b.Config) = 0 AND LTRIM(RTRIM(ISNULL(b.Config, N''))) <> N'';
+INSERT @c6
+SELECT p.DriverType, p.DriverName, p.DriverInstanceId, p.BlobKind, p.OwnerName, p.FieldPath,
+ CASE WHEN p.PropType <> 1 THEN N'#836 UserCertificatePath is not a string'
+ ELSE N'#836 UserCertificatePath must be a relative path inside Drivers:OpcUaClient:UserCertificateDirectory' END,
+ CASE WHEN p.PropType = 1 THEN p.PropValue END,
+ CASE WHEN a.AuthType IS NULL OR a.AuthTypeType = 0 OR (a.AuthTypeType = 1 AND LOWER(a.AuthType) = N'certificate')
+ OR (a.AuthTypeType = 2 AND (TRY_CAST(a.AuthType AS int) IS NULL OR TRY_CAST(a.AuthType AS int) = 2)) OR a.AuthTypeType NOT IN (0, 1, 2)
+ THEN N'blocking' ELSE N'review' END
+FROM @Prop p
+OUTER APPLY (SELECT TOP 1 o.[value] AS AuthType, o.[type] AS AuthTypeType FROM OPENJSON(p.ParentJson) o WHERE LOWER(o.[key]) = N'authtype') a
+WHERE p.DriverType = N'OpcUaClient' AND LOWER(p.PropKey) = N'usercertificatepath'
+ AND p.PropType <> 0 AND NOT (p.PropType = 1 AND p.PropValue = N'')
+ AND (p.PropType <> 1
+ OR LEN(p.PropValue) > 260
+ OR LEFT(p.PropValue, 1) IN (N'/', N'\')
+ OR p.PropValue LIKE N'_:%'
+ OR N'/' + REPLACE(p.PropValue, N'\', N'/') + N'/' LIKE N'%//%'
+ OR N'/' + REPLACE(p.PropValue, N'\', N'/') + N'/' LIKE N'%/./%'
+ OR N'/' + REPLACE(p.PropValue, N'\', N'/') + N'/' LIKE N'%/../%'
+ OR REPLACE(p.PropValue, N'\', N'/') LIKE N'%[^-A-Za-z0-9._/]%');
+SELECT N'6' AS check_id, N'#815/#836 CredentialReferenceOutOfScope' AS title,
+ CASE WHEN SUM(CASE WHEN severity = N'blocking' THEN 1 ELSE 0 END) > 0 THEN N'attention' WHEN COUNT(*) > 0 THEN N'info' ELSE N'ok' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'CredentialReferenceDeployChecks / DriverCredentialPolicy; commits a224fd63, 63331b04, 3dbfe6ec' AS source FROM @c6;
+SELECT * FROM @c6 ORDER BY severity, DriverType, DriverName, field_path;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 7 — #835/#849 CredentialReferenceOtherInstance (BLOCKING)
+ master: DriverCredentialPolicy.DescribeOtherInstanceProblems via CredentialReferenceDeployChecks (63331b04 #835,
+ 4a6229e4 #849, d68dff09). An in-scope reference whose instance part names ANOTHER driver of the same type:
+ secret://... X equals another driver's id or name (case-insensitive);
+ env:... X equals another driver's id/name in env form (ASCII letters upper-cased, digits kept, every
+ other char '_'), alone or followed by '_'.
+ An id-shaped part (secret DRV-<12 hex>, env DRV_<12 HEX>) matches by id only.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @Drv TABLE (DriverInstanceId nvarchar(64), Name nvarchar(128), DriverType nvarchar(32), IdNorm nvarchar(256) COLLATE Latin1_General_BIN2, NameNorm nvarchar(256) COLLATE Latin1_General_BIN2);
+INSERT @Drv
+SELECT d.DriverInstanceId, d.Name, d.DriverType,
+ CASE WHEN d.DriverInstanceId COLLATE Latin1_General_BIN2 LIKE N'%[A-Za-z0-9]%' THEN
+ (SELECT CASE WHEN SUBSTRING(d.DriverInstanceId, n.n, 1) COLLATE Latin1_General_BIN2 LIKE N'[A-Za-z0-9]' THEN UPPER(SUBSTRING(d.DriverInstanceId, n.n, 1)) ELSE N'_' END
+ FROM @Num n WHERE n.n <= DATALENGTH(d.DriverInstanceId) / 2 ORDER BY n.n FOR XML PATH('')) ELSE N'' END,
+ CASE WHEN d.Name COLLATE Latin1_General_BIN2 LIKE N'%[A-Za-z0-9]%' THEN
+ (SELECT CASE WHEN SUBSTRING(d.Name, n.n, 1) COLLATE Latin1_General_BIN2 LIKE N'[A-Za-z0-9]' THEN UPPER(SUBSTRING(d.Name, n.n, 1)) ELSE N'_' END
+ FROM @Num n WHERE n.n <= DATALENGTH(d.Name) / 2 ORDER BY n.n FOR XML PATH('')) ELSE N'' END
+FROM dbo.DriverInstance d WHERE d.DriverType IN (SELECT DriverType FROM @Field);
+
+DECLARE @InstRef TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerName nvarchar(128), FieldPath nvarchar(4000),
+ Kind nvarchar(10), Part nvarchar(4000) COLLATE Latin1_General_BIN2, IdShaped bit, Shown nvarchar(4000));
+INSERT @InstRef
+SELECT c.DriverInstanceId, c.DriverName, c.DriverType, c.BlobKind, c.OwnerName, c.FieldPath, c.Kind, x.Part,
+ CASE WHEN c.Kind = N'secret' THEN CASE WHEN LEN(x.Part) = 16 AND UPPER(LEFT(x.Part, 4)) = N'DRV-' AND SUBSTRING(x.Part, 5, 12) NOT LIKE N'%[^0-9A-Fa-f]%' THEN 1 ELSE 0 END
+ ELSE CASE WHEN LEFT(x.Part, 4) = N'DRV_' AND LEN(x.Part) >= 16 AND SUBSTRING(x.Part, 5, 12) NOT LIKE N'%[^0-9A-F]%'
+ AND (LEN(x.Part) = 16 OR SUBSTRING(x.Part, 17, 1) = N'_') THEN 1 ELSE 0 END END,
+ c.Shown
+FROM @Cred c
+CROSS APPLY (SELECT TOP 1 n.Prefix FROM @Ns n WHERE n.DriverType = c.DriverType AND n.Kind = c.Kind AND LEFT(c.RefName, LEN(n.Prefix)) = n.Prefix ORDER BY LEN(n.Prefix) DESC) ns
+CROSS APPLY (SELECT SUBSTRING(c.RefName, LEN(ns.Prefix) + 1, 4000) AS Rest) r
+CROSS APPLY (SELECT CASE WHEN c.Kind = N'secret' AND CHARINDEX(N'/', r.Rest) > 0 THEN LEFT(r.Rest, CHARINDEX(N'/', r.Rest) - 1) ELSE r.Rest END AS Part) x
+WHERE c.Kind IN (N'secret', N'env');
+
+DECLARE @c7 TABLE (DriverType nvarchar(32), DriverName nvarchar(128), DriverInstanceId nvarchar(64), blob nvarchar(30), owner_name nvarchar(128), field_path nvarchar(4000),
+ reference nvarchar(4000), names_other_driver nvarchar(128), other_driver_id nvarchar(64), matched_on nvarchar(10));
+INSERT @c7
+SELECT i.DriverType, i.DriverName, i.DriverInstanceId, i.BlobKind, i.OwnerName, i.FieldPath, i.Shown, o.Name, o.DriverInstanceId,
+ CASE WHEN (i.Kind = N'secret' AND LOWER(i.Part) = LOWER(o.DriverInstanceId) COLLATE Latin1_General_BIN2)
+ OR (i.Kind = N'env' AND o.IdNorm <> N'' AND (i.Part = o.IdNorm OR LEFT(i.Part, LEN(o.IdNorm) + 1) = o.IdNorm + N'_')) THEN N'id' ELSE N'name' END
+FROM @InstRef i
+INNER JOIN @Drv o ON LOWER(o.DriverType) = LOWER(i.DriverType) AND o.DriverInstanceId <> i.DriverInstanceId
+WHERE (i.Kind = N'secret' AND (LOWER(i.Part) = LOWER(o.DriverInstanceId) COLLATE Latin1_General_BIN2 OR (i.IdShaped = 0 AND LOWER(i.Part) = LOWER(o.Name) COLLATE Latin1_General_BIN2)))
+ OR (i.Kind = N'env' AND (
+ (o.IdNorm <> N'' AND (i.Part = o.IdNorm OR LEFT(i.Part, LEN(o.IdNorm) + 1) = o.IdNorm + N'_'))
+ OR (i.IdShaped = 0 AND o.NameNorm <> N'' AND (i.Part = o.NameNorm OR LEFT(i.Part, LEN(o.NameNorm) + 1) = o.NameNorm + N'_'))));
+SELECT N'7' AS check_id, N'#835/#849 CredentialReferenceOtherInstance' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'DriverCredentialPolicy.DescribeOtherInstanceProblems; commits 63331b04, 4a6229e4, d68dff09' AS source FROM @c7;
+SELECT * FROM @c7 ORDER BY DriverType, DriverName, field_path;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 8 — #848/#849 reserved / claimed driver names (AUTHORING, not a deploy refusal)
+ master: DriverCredentialPolicy.IsReservedInstanceName + DriverConfigSaveGate.DescribeNameChangeProblemsAsync (d08e59ee,
+ d68dff09). A credential-bearing driver named 'drv' / 'DRV-<12 hex>...' (env form DRV or DRV_<12 HEX>) cannot be
+ created or renamed to that name; listed so a later rename/save surprise is expected.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c8 TABLE (DriverType nvarchar(32), DriverName nvarchar(128), DriverInstanceId nvarchar(64), env_form nvarchar(256), finding nvarchar(200));
+INSERT @c8
+SELECT d.DriverType, d.Name, d.DriverInstanceId, d.NameNorm, N'name is reserved (collides with the DRV- credential convention)'
+FROM @Drv d
+WHERE d.NameNorm = N'DRV'
+ OR (LEFT(d.NameNorm, 4) = N'DRV_' AND LEN(d.NameNorm) >= 16 AND SUBSTRING(d.NameNorm, 5, 12) NOT LIKE N'%[^0-9A-F]%'
+ AND (LEN(d.NameNorm) = 16 OR SUBSTRING(d.NameNorm, 17, 1) = N'_'));
+SELECT N'8' AS check_id, N'#848/#849 reserved driver names (authoring only)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'NO (save/rename refused)' AS blocking, N'DriverCredentialPolicy.IsReservedInstanceName; commits d08e59ee, d68dff09' AS source FROM @c8;
+SELECT * FROM @c8;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 9 — MTConnectMultipleDevices (BLOCKING)
+ master: DraftValidator.ValidateMTConnectSingleDevice (dcc827ca, #628). More than one /raw Device under one
+ MTConnect driver instance.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c9 TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), device_count int, devices nvarchar(max));
+INSERT @c9
+SELECT d.DriverInstanceId, d.Name, COUNT(*),
+ STUFF((SELECT N', ' + v2.Name FROM dbo.Device v2 WHERE v2.DriverInstanceId = d.DriverInstanceId FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N'')
+FROM dbo.DriverInstance d INNER JOIN dbo.Device v ON v.DriverInstanceId = d.DriverInstanceId
+WHERE d.DriverType COLLATE Latin1_General_BIN2 = N'MTConnect'
+GROUP BY d.DriverInstanceId, d.Name HAVING COUNT(*) > 1;
+SELECT N'9' AS check_id, N'MTConnectMultipleDevices' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateMTConnectSingleDevice; commit dcc827ca (#628)' AS source FROM @c9;
+SELECT * FROM @c9;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 10 — #781 Sql tag gates
+ 10a SqlQueryNotDeclared (BLOCKING, approximate): a Query-model tag ("model":"Query") whose "query" is not a key of
+ the driver's merged "queries" map (DriverConfig, shallow-overridden by the DeviceConfig when the driver has
+ exactly one Device — DriverDeviceConfigMerger). master: SqlTagDeployChecks.UndeclaredQueryErrors (241318e7).
+ 10b Sql tags carrying a "write" block (REVIEW): the block is now parsed (f5c30ed0); an invalid block is
+ SqlWriteBlockInvalid (BLOCKING, and the driver would skip the whole tag), Upsert/Procedure on an Odbc provider
+ is SqlWriteModeUnsupported (BLOCKING). master: SqlTagDeployChecks.WriteBlockErrors.
+ 10c Writable mismatch (WARNING only, logged at deploy): Tag.AccessLevel=ReadWrite vs TagConfig "writable": true.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c10 TABLE (sub nvarchar(4), TagId nvarchar(64), RawPath nvarchar(4000), DriverName nvarchar(128), finding nvarchar(300), severity nvarchar(10));
+WITH sqlTag AS (
+ SELECT r.*, (SELECT COUNT(*) FROM dbo.Device v WHERE v.DriverInstanceId = r.DriverInstanceId) AS DeviceCount
+ FROM @RawTag r WHERE r.DriverType = N'Sql' AND ISJSON(r.TagConfig) = 1
+)
+INSERT @c10
+SELECT N'10a', t.TagId, t.RawPath, t.DriverName, N'query ''' + q.QueryName + N''' not declared under the driver''s queries', N'blocking'
+FROM sqlTag t
+CROSS APPLY (SELECT JSON_VALUE(t.TagConfig, '$.query') AS QueryName, JSON_VALUE(t.TagConfig, '$.model') AS Model) q
+OUTER APPLY (SELECT TOP 1 v.DeviceConfig FROM dbo.Device v WHERE v.DriverInstanceId = t.DriverInstanceId AND t.DeviceCount = 1
+ AND ISJSON(v.DeviceConfig) = 1 AND EXISTS (SELECT 1 FROM OPENJSON(v.DeviceConfig) o WHERE o.[key] COLLATE Latin1_General_BIN2 = N'queries')) dev
+CROSS APPLY (SELECT ISNULL(dev.DeviceConfig, (SELECT di.DriverConfig FROM dbo.DriverInstance di WHERE di.DriverInstanceId = t.DriverInstanceId)) AS Cfg) m
+WHERE LOWER(q.Model) = N'query' AND q.QueryName IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM OPENJSON(CASE WHEN ISJSON(m.Cfg) = 1 THEN m.Cfg END) top1
+ CROSS APPLY OPENJSON(CASE WHEN top1.[type] = 5 THEN top1.[value] END) qk
+ WHERE LOWER(top1.[key]) = N'queries' AND qk.[key] COLLATE Latin1_General_BIN2 = q.QueryName COLLATE Latin1_General_BIN2);
+INSERT @c10
+SELECT N'10b', r.TagId, r.RawPath, r.DriverName, N'TagConfig has a "write" block — verify it parses (mode/table/keys) and the provider serves the mode', N'review'
+FROM @RawTag r WHERE r.DriverType = N'Sql' AND ISJSON(r.TagConfig) = 1
+ AND EXISTS (SELECT 1 FROM OPENJSON(r.TagConfig) o WHERE o.[key] COLLATE Latin1_General_BIN2 = N'write');
+INSERT @c10
+SELECT N'10c', r.TagId, r.RawPath, r.DriverName,
+ CASE WHEN r.AccessLevel = N'ReadWrite' THEN N'AccessLevel ReadWrite but TagConfig lacks "writable": true — writes refused'
+ ELSE N'TagConfig "writable": true but AccessLevel is not ReadWrite — node read-only' END, N'warning'
+FROM @RawTag r
+CROSS APPLY (SELECT CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) o
+ WHERE o.[key] COLLATE Latin1_General_BIN2 = N'writable' AND o.[type] = 3 AND o.[value] = N'true') THEN 1 ELSE 0 END AS BlobWritable) w
+WHERE r.DriverType = N'Sql' AND (CASE WHEN r.AccessLevel = N'ReadWrite' THEN 1 ELSE 0 END) <> w.BlobWritable;
+SELECT N'10' AS check_id, N'#781 Sql tag gates' AS title,
+ CASE WHEN SUM(CASE WHEN severity IN (N'blocking', N'review') THEN 1 ELSE 0 END) > 0 THEN N'attention' WHEN COUNT(*) > 0 THEN N'info' ELSE N'ok' END AS status,
+ COUNT(*) AS row_count, N'10a/10b YES, 10c NO' AS blocking, N'SqlTagDeployChecks; commits 241318e7, f5c30ed0' AS source FROM @c10;
+SELECT * FROM @c10 ORDER BY sub, RawPath;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 11 — #803 Heidenhain gates (BLOCKING) — sanity only: the Heidenhain driver type did not exist at b8b4c18f,
+ so any row here was written by something other than the b8b4c18f AdminUI.
+ master: HeidenhainDeployChecks (2b45694f).
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c11 TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128));
+INSERT @c11 SELECT DriverInstanceId, Name FROM dbo.DriverInstance WHERE LOWER(DriverType) = N'heidenhain';
+SELECT N'11' AS check_id, N'#803 Heidenhain drivers present (expected none)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'HeidenhainDeployChecks; commit 2b45694f' AS source FROM @c11;
+SELECT * FROM @c11;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 12 — UNS effective-name collisions (pre-existing rule, unchanged; re-checked because the whole DB is the
+ draft). master: DraftValidator.ValidateUnsEffectiveLeafUniqueness. References (DisplayNameOverride else raw tag
+ Name), VirtualTags and ScriptedAlarms share {EquipmentId}/{EffectiveName}; compared ordinally.
+ --------------------------------------------------------------------------------------------- */
+DECLARE @Leaf TABLE (EquipmentId nvarchar(64) COLLATE Latin1_General_BIN2, EffName nvarchar(128) COLLATE Latin1_General_BIN2, Src nvarchar(200));
+INSERT @Leaf SELECT u.EquipmentId, ISNULL(u.DisplayNameOverride, t.Name), N'UnsTagReference ' + u.UnsTagReferenceId
+FROM dbo.UnsTagReference u LEFT JOIN dbo.Tag t ON t.TagId = u.TagId WHERE ISNULL(u.DisplayNameOverride, t.Name) <> N'';
+INSERT @Leaf SELECT EquipmentId, Name, N'VirtualTag ' + VirtualTagId FROM dbo.VirtualTag WHERE Name <> N'';
+INSERT @Leaf SELECT EquipmentId, Name, N'ScriptedAlarm ' + ScriptedAlarmId FROM dbo.ScriptedAlarm WHERE Name <> N'';
+DECLARE @c12 TABLE (EquipmentId nvarchar(64), EffectiveName nvarchar(128), sources nvarchar(max));
+INSERT @c12
+SELECT l.EquipmentId, l.EffName,
+ STUFF((SELECT N', ' + l2.Src FROM @Leaf l2 WHERE l2.EquipmentId = l.EquipmentId AND l2.EffName = l.EffName FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N'')
+FROM @Leaf l GROUP BY l.EquipmentId, l.EffName HAVING COUNT(*) > 1;
+SELECT N'12' AS check_id, N'UnsEffectiveName collisions (pre-existing rule)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status,
+ COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateUnsEffectiveLeafUniqueness (unchanged since b8b4c18f)' AS source FROM @c12;
+SELECT * FROM @c12 ORDER BY EquipmentId, EffectiveName;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 13 — FOCAS tags whose authored address or value changes under master (the box's one driver)
+ Not deploy refusals: the TagConfig inspector reports 'String' mismatches at deploy as WARNINGS
+ (Deployment:TagConfigValidationMode = Warn, the default) and the driver skips that tag (BadNodeIdUnknown).
+ rename FIXED: ...MaxRpm -> MotorSpeedPct (1dba46bd)
+ rename FIXED:Axes/AA_1, AA_2 -> AA6, AA7 on the Makino (9460da51 — old names were a decode artefact)
+ refused dataType String on a PMC / PARAM: / MACRO: address (cc140593, R07:U8)
+ value FIXED:Axes/... positions scaled by the block envelope (69e0a622: 2801574 -> 280.1574)
+ value servo/spindle Load: real percent, spindle Load Float64 (1dba46bd)
+ value MACRO: decoded at the right offset (665ecd55: was off by 10^n); vacant -> BadNoData,
+ fractional on an integer tag -> BadTypeMismatch
+ value FIXED:Program/...: an unreported field -> BadNoData instead of Good 0 (26b512ad)
+ --------------------------------------------------------------------------------------------- */
+DECLARE @c13 TABLE (TagId nvarchar(64), RawPath nvarchar(4000), address nvarchar(400), tag_config_dataType nvarchar(40), entity_DataType nvarchar(32), finding nvarchar(300), severity nvarchar(10));
+INSERT @c13
+SELECT r.TagId, r.RawPath, a.Addr, a.Dt, r.DataType, f.Finding, f.Severity
+FROM @RawTag r
+CROSS APPLY (SELECT LTRIM(RTRIM(JSON_VALUE(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END, '$.address'))) AS Addr,
+ JSON_VALUE(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END, '$.dataType') AS Dt) a
+CROSS APPLY (
+ SELECT N'rename: MaxRpm is now MotorSpeedPct — re-point the tag (and ScadaBridge)' AS Finding, N'attention' AS Severity WHERE a.Addr LIKE N'%MaxRpm%'
+ UNION ALL SELECT N'rename: AA_1/AA_2 are now the real axis names (AA6/AA7 on Z-34184) — re-point', N'attention' WHERE a.Addr LIKE N'%AA[_]1%' OR a.Addr LIKE N'%AA[_]2%'
+ UNION ALL SELECT N'refused: String on a PMC/PARAM/MACRO address — driver skips the tag', N'attention'
+ WHERE LOWER(a.Dt) = N'string' AND LOWER(LEFT(a.Addr, 6)) <> N'fixed:' AND LOWER(LEFT(a.Addr, 6)) <> N'alarm:'
+ UNION ALL SELECT N'value change: position now scaled by its envelope', N'info' WHERE LOWER(a.Addr) LIKE N'fixed:axes/%' AND LOWER(a.Addr) LIKE N'%position%'
+ UNION ALL SELECT N'value change: load now a real percent (spindle Load Float64)', N'info' WHERE LOWER(a.Addr) LIKE N'fixed:%load%'
+ UNION ALL SELECT N'value change: macro decoded correctly (magnitude changes); vacant = BadNoData', N'info' WHERE LOWER(a.Addr) LIKE N'macro:%'
+ UNION ALL SELECT N'value change: unreported program field reads BadNoData, not Good 0', N'info' WHERE LOWER(a.Addr) LIKE N'fixed:program%'
+) f
+WHERE r.DriverType = N'FOCAS';
+SELECT N'13' AS check_id, N'FOCAS address renames / refusals / value changes' AS title,
+ CASE WHEN SUM(CASE WHEN severity = N'attention' THEN 1 ELSE 0 END) > 0 THEN N'attention' WHEN COUNT(*) > 0 THEN N'info' ELSE N'ok' END AS status,
+ COUNT(*) AS row_count, N'NO (warn at deploy; client-visible)' AS blocking, N'commits 1dba46bd, 9460da51, cc140593, 69e0a622, 665ecd55, 26b512ad' AS source FROM @c13;
+SELECT * FROM @c13 ORDER BY severity, RawPath;
+
+/* ---------------------------------------------------------------------------------------------
+ Check 14 — inventory (INFO): what the box actually runs, so the owner can discount driver-specific rows.
+ --------------------------------------------------------------------------------------------- */
+SELECT N'14' AS check_id, N'Inventory' AS title, N'info' AS status,
+ (SELECT COUNT(*) FROM dbo.DriverInstance) AS row_count, N'NO' AS blocking, N'-' AS source;
+SELECT d.DriverType, COUNT(DISTINCT d.DriverInstanceId) AS drivers, SUM(CASE WHEN d.Enabled = 1 THEN 1 ELSE 0 END) AS enabled_driver_rows,
+ (SELECT COUNT(*) FROM @RawTag r WHERE r.DriverType = d.DriverType COLLATE Latin1_General_BIN2) AS raw_tags
+FROM dbo.DriverInstance d GROUP BY d.DriverType
+UNION ALL SELECT N'(VirtualTag)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.VirtualTag
+UNION ALL SELECT N'(ScriptedAlarm)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.ScriptedAlarm
+UNION ALL SELECT N'(Script)', COUNT(*), NULL, NULL FROM dbo.Script
+UNION ALL SELECT N'(UnsTagReference)', COUNT(*), NULL, NULL FROM dbo.UnsTagReference
+UNION ALL SELECT N'(ApiKey)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.ApiKey
+UNION ALL SELECT N'(ClusterNode)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.ClusterNode;
]