docs(deploy): vd03 upgrade-readiness pack b8b4c18f -> master (#793)
v2-ci / unit-tests (push) Waiting to run

Read-only ConfigDb preflight (scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql)
covering every deploy gate added since the deployed build: VirtualTag/Calculation effective
triggers (#783), VT->VT reads and VirtualTagDependencyCycle (#791), newly forbidden script
APIs (#792, E2), credential-reference namespace / other-instance / reserved-name rules
(#815 #835 #836 #848 #849 #850), MTConnectMultipleDevices, Sql query/write gates (#781),
Heidenhain, UNS effective-name collisions and the FOCAS renames/value changes. Verified on a
throwaway b8b4c18f-schema database (every check fires on seeded rows) and clean against the
docker-dev config.

Upgrade notes (docs/deploy/vd03-upgrade-b8b4c18f-to-master.md): the EnableSnapshotIsolation
migration and how to apply it by hand, the env-var audit (Security:ApiKeys:Pepper is now
required and absent on the box; JWT key validated at start), SDK 2.0.1 / CA KeyUsage, the
ScadaBridge-visible changes, swap/rollback and post-deploy checks.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 16:13:10 -05:00
parent 363f0d8103
commit 88698ca042
2 changed files with 1001 additions and 0 deletions
@@ -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 <dir>`.
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 <backup> /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 <instance> -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_<hex>` 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.
@@ -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 '<literal redacted>'.
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("<ref>") 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: <folder chain>/<DriverName>/<DeviceName>/<tag-group chain>/<TagName> (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("<EquipmentId>/<VtName>")
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}}/<Name> 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}}/<RefName>
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}}/<RefName> 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'<literal redacted>' WHEN k.Kind = N'non-string' THEN N'<non-string JSON value>'
WHEN LOWER(LEFT(p.PropValue, 4)) = N'dev:' THEN N'dev:<redacted>' 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:<ns>/<X>/... X equals another driver's id or name (case-insensitive);
env:<PREFIX><X>... 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-<id> 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;