merge(r2): r2-plan05

This commit is contained in:
Joseph Doherty
2026-07-13 11:08:27 -04:00
15 changed files with 772 additions and 55 deletions
+2 -2
View File
@@ -95,7 +95,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
21. Notification Outbox — Central component ingesting store-and-forwarded notifications, `Notifications` audit table, dispatcher loop, retry/parking, delivery KPIs.
22. Site Call Audit — Central component auditing site cached calls (`CachedCall`/`CachedWrite`); `SiteCalls` audit table, telemetry ingest, reconciliation, KPIs, central→site Retry/Discard relay; sites remain the source of truth.
23. Audit Log — Central append-only AuditLog table spanning every script-trust-boundary action (outbound API sync+cached, outbound DB sync+cached, notifications, inbound API). Site SQLite hot-path + gRPC telemetry + reconciliation; combined telemetry with Site Call Audit; central direct-write for Notification Outbox dispatch + Inbound API; monthly partitioning, 365-day retention.
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates (with **all** child collections — attributes/alarms/scripts/compositions/**native-alarm-sources**, field-complete incl. `LockedInDerived` + script cadence/timeout), system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s + real `AreaName` by name) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Runs the **script trust gate** at import review (5th `ScriptTrustValidator` call site — forbidden-API scripts rejected pre-runtime; template-script name-resolution findings are advisory warnings, ApiMethod findings hard-block). Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
24. Transport — File-based, encrypted bundle export/import via Central UI. Templates (with **all** child collections — attributes/alarms/scripts/compositions/**native-alarm-sources**, field-complete incl. `LockedInDerived` + script cadence/timeout), system artifacts, central-only configuration, plus site/instance-scoped config (`Site`s, site `DataConnection`s, `Instance`s + real `AreaName` by name) reconciled across environments by a `BundleNameMap` name-mapping subsystem. Per-conflict resolution with a per-line Myers diff. Runs the **script trust gate** at import review (5th `ScriptTrustValidator` call site — forbidden-API scripts rejected pre-runtime; covers template/shared/ApiMethod bodies, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** — all hard-block; template-script name-resolution findings are advisory warnings, ApiMethod findings hard-block). Correlated audit via `BundleImportId`. Never touches site runtime nodes (imported instances land `NotDeployed`).
25. Script Analysis — Shared authoritative script-trust analyzer: unified forbidden-API deny-list (`ScriptTrustPolicy`), fused semantic+syntactic validator (`ScriptTrustValidator`), Roslyn compile wrapper (`RoslynScriptCompiler`), and compile-only globals stubs (`ScriptCompileSurface`/`TriggerCompileSurface`); consumed by Template Engine, Site Runtime, Inbound API, and Central UI.
26. KPI History — Reusable central KPI-history backbone: tall/EAV `KpiSample` store in central MS SQL, `KpiHistoryRecorderActor` cluster singleton (`kpi-history-recorder`, not readiness-gated) sampling DI-registered `IKpiSampleSource`s every minute, bucketed query (`GetRawSeriesAsync` + `KpiSeriesBucketer`) + scoped `KpiHistoryQueryService`, and a reusable custom-SVG `KpiTrendChart`; ships trends for Notification Outbox, Site Call Audit, Audit Log, and Site Health.
27. DelmiaNotifier — Standalone external client tool (NOT a cluster component, NOT in the Host): a compact Native-AOT (`win-x64`) console app (`WWNotifier.exe`) that DELMIA Apriso shells out to per recipe download. POSTs to the Inbound API `DelmiaRecipeDownload` method (`X-API-Key`, key from `SCADABRIDGE_API_KEY`), with connect-failure-only failover across a comma-list of base URLs, and reports the legacy `YES`/`NO` + exit-code stdout contract — a drop-in replacement for the legacy `WWNotifier` (see `docs/former-api-specs/dnc/`). Zero-dependency BCL-only, `System.Text.Json` source-gen. Project README: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`; design: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md`.
@@ -150,7 +150,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): `
- Last-write-wins for concurrent template editing (no optimistic concurrency on templates).
- Optimistic concurrency on deployment status records.
- Naming collisions in composed feature modules are design-time errors.
- Transport (#24, M8): bundles now move site/instance config (`Site`s, site-scoped `DataConnection`s, `Instance`s + override children + `Area` by name), not just central-only config — but still never touch site runtime nodes (imported instances land `NotDeployed`). A `BundleNameMap` name-mapping subsystem (Commons: `SiteMapping`/`ConnectionMapping`/`MappingAction`) reconciles environment-specific identifiers (sites by `SiteIdentifier`, connections by site-qualified `{SiteIdentifier}/{Name}`, instances by `UniqueName`): auto-match → operator override via the import-wizard Map step / CLI `--map-site`/`--map-connection`/`--create-missing-*` → blocker rows for the unresolved. D3 "carry full config (encrypted secrets)": Site addresses travel; `DataConnection` config rides the encrypted `SecretsBlock` (presence-only in diffs). Per-line Myers diff for code fields via the pure `LineDiffer` (`ArtifactDiff` embeds a size-capped structured `lineDiff`; input capped at `MaxInputLines`=4000 → summary-only, so a crafted bundle can't OOM the active node); `ImportResult.StaleInstanceIds` is real via the `IStaleInstanceProbe` seam (Commons; implemented in DeploymentManager). `schemaVersion` 1.0→1.1 (additive); `bundleFormatVersion` stays 1; no new EF tables/columns. **Arch-review 05 (plan 05):** template child fidelity completed (round-trip-guard-verified: `LockedInDerived`, native-alarm-sources, script cadence/timeout, real `AreaName`, ES retry config + methods-on-Add, notification recipients, folder parent edges); import runs the **script trust gate** (template/shared/ApiMethod bodies **and** Expression-trigger bodies) as a hard pre-runtime gate + a name-resolution severity split (template=advisory `ConflictKind.Warning`+`ImportResult.Warnings`, ApiMethod=hard error); `ArtifactDiff` resolves folder/base/composition names (unchanged structured templates classify Identical again); `MaxConcurrentImportSessions`=8 session cap. Rename does NOT rewrite call sites (deploy gate on the target is the real check). Plan-06 handoff: `ScriptArtifactsChanged`/`IScriptArtifactChangeBus` seam + Host in-process bus + Transport post-commit publisher shipped (consumers = plan 06).
- Transport (#24, M8): bundles now move site/instance config (`Site`s, site-scoped `DataConnection`s, `Instance`s + override children + `Area` by name), not just central-only config — but still never touch site runtime nodes (imported instances land `NotDeployed`). A `BundleNameMap` name-mapping subsystem (Commons: `SiteMapping`/`ConnectionMapping`/`MappingAction`) reconciles environment-specific identifiers (sites by `SiteIdentifier`, connections by site-qualified `{SiteIdentifier}/{Name}`, instances by `UniqueName`): auto-match → operator override via the import-wizard Map step / CLI `--map-site`/`--map-connection`/`--create-missing-*` → blocker rows for the unresolved. D3 "carry full config (encrypted secrets)": Site addresses travel; `DataConnection` config rides the encrypted `SecretsBlock` (presence-only in diffs). Per-line Myers diff for code fields via the pure `LineDiffer` (`ArtifactDiff` embeds a size-capped structured `lineDiff`; input capped at `MaxInputLines`=4000 → summary-only, so a crafted bundle can't OOM the active node); `ImportResult.StaleInstanceIds` is real via the `IStaleInstanceProbe` seam (Commons; implemented in DeploymentManager). `schemaVersion` 1.0→1.1 (additive); `bundleFormatVersion` stays 1; no new EF tables/columns. **Arch-review 05 (plan 05):** template child fidelity completed (round-trip-guard-verified: `LockedInDerived`, native-alarm-sources, script cadence/timeout, real `AreaName`, ES retry config + methods-on-Add, notification recipients, folder parent edges); import runs the **script trust gate** (template/shared/ApiMethod bodies, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** — R2-05) as a hard pre-runtime gate + a name-resolution severity split (template=advisory `ConflictKind.Warning`+`ImportResult.Warnings`, ApiMethod=hard error); `ArtifactDiff` resolves folder/base/composition names (unchanged structured templates classify Identical again); `MaxConcurrentImportSessions`=8 session cap (startup-validated `>0`, R2-05). **R2-05 also:** the post-commit `ScriptArtifactsChanged` publisher now covers `Add` resolutions (not just Overwrite/Rename), import warns on instance overrides targeting locked template members (advisory, rows still written), and the Expression-trigger syntax check is surface-keyed-cached + skipped on read-only staleness/comparison paths (`validateScriptCompilation:false`). Rename does NOT rewrite call sites (deploy gate on the target is the real check). Plan-06 handoff: `ScriptArtifactsChanged`/`IScriptArtifactChangeBus` seam + Host in-process bus + Transport post-commit publisher shipped (consumers = plan 06).
### Store-and-Forward
- Fixed retry interval, no max buffer size. Only transient failures buffered.
@@ -151,6 +151,8 @@ This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`
Mirrors `TriggerExpressionGlobals` in the same way. Used by `ValidationService.CheckExpressionSyntax` in the Template Engine for conditional and expression trigger validation.
`CheckExpressionSyntax` memoises its verdict in the Template Engine's process-wide `ScriptCompileVerdictCache`, whose key is the pair **(globals surface, SHA-256 of the code)** — not the code alone. The surface discriminator is load-bearing: a trigger expression vetted against `TriggerCompileSurface` is **not** interchangeable with a `ScriptCompileSurface` script-body verdict (different globals resolve different identifiers), so a code-only key could return a stale "clean" for code never compiled against the caller's surface. Keying by surface makes that cross-surface verdict reuse structurally impossible.
#### Parity guard
A reflection-based parity test in `SiteRuntime.Tests` compares the public member names on `ScriptCompileSurface` against `ScriptGlobals` (and `TriggerCompileSurface` against `TriggerExpressionGlobals`). Any drift between the stub and the real globals causes this test to fail, ensuring the stubs cannot silently fall out of sync.
@@ -170,7 +172,7 @@ All five call sites that previously maintained their own script trust enforcemen
| **Site Runtime** `ScriptCompilationService.ValidateTrustModel` | Semantic resolver, no reflection-gateway hardening | Delegates to `FindViolations`; retains `CSharpScript.Compile` against real `ScriptGlobals` for execution |
| **Inbound API** `ForbiddenApiChecker.FindViolations` | Syntactic walker, forbade all `System.Diagnostics` | Thin shim delegating to `ScriptTrustValidator.FindViolations`; `System.Diagnostics` loosened to `.Process`-only |
| **Central UI** `ScriptAnalysisService` | Semantic + full compile, lenient threading | Delegates forbidden-API verdict and sources editor-marker deny-list from `ScriptTrustPolicy`; retains Test-Run execution host |
| **Transport** `BundleImporter.RunSemanticValidationAsync` / `DetectBlockersAsync` | No trust gate — a bundle's forbidden-API scripts imported clean, deferred to runtime (fifth write path) | Runs `ScriptTrustValidator.FindViolations` over every non-Skip template / shared / ApiMethod body at import review — hard error (apply) / Blocker row (preview) for all kinds |
| **Transport** `BundleImporter.RunSemanticValidationAsync` / `DetectBlockersAsync` | No trust gate — a bundle's forbidden-API scripts imported clean, deferred to runtime (fifth write path) | Runs `ScriptTrustValidator.FindViolations` over every non-Skip template / shared / ApiMethod body, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** at import review — hard error (apply) / Blocker row (preview) for all kinds |
The static enforcement is **defence-in-depth**, not a true runtime sandbox. Scripts execute in-process; the denied API list prevents obvious escapes at compile time but does not provide the isolation guarantees of an out-of-process sandbox or a restricted `AssemblyLoadContext`. This caveat applies to all consumers.
@@ -190,4 +192,4 @@ No dependency on Akka.NET, ASP.NET Core, Entity Framework, or any other ScadaBri
- **Site Runtime (#3)**: `ScriptCompilationService.ValidateTrustModel` delegates the trust verdict to `ScriptTrustValidator.FindViolations`; retains its own `CSharpScript.Compile` against the real `ScriptGlobals` for execution-time compilation.
- **Inbound API (#14)**: `ForbiddenApiChecker.FindViolations` is a thin shim over `ScriptTrustValidator.FindViolations`.
- **Central UI (#9)**: `ScriptAnalysisService` delegates the run-gate forbidden-API verdict and sources the editor-marker deny-list from `ScriptTrustPolicy`; retains the Test-Run execution host (`SandboxScriptHost`).
- **Transport (#24)**: `BundleImporter` runs `ScriptTrustValidator.FindViolations` over every non-Skip template, shared, and ApiMethod script body during import validation (`RunSemanticValidationAsync` Pass 0) and preview (`DetectBlockersAsync`), rejecting forbidden-API scripts at import review rather than at runtime. Trust violations are hard errors for all script kinds (unlike the false-positive-prone name-resolution heuristic, whose template-script findings are advisory).
- **Transport (#24)**: `BundleImporter` runs `ScriptTrustValidator.FindViolations` over every non-Skip template, shared, and ApiMethod script body, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`) during import validation (`RunSemanticValidationAsync` Pass 0) and preview (`DetectBlockersAsync`), rejecting forbidden-API scripts at import review rather than at runtime. Trust violations are hard errors for all script kinds (unlike the false-positive-prone name-resolution heuristic, whose template-script findings are advisory).
@@ -184,6 +184,8 @@ Before a deployment is sent to a site, the Template Engine performs comprehensiv
- **Script compilation and trust check**: All instance scripts and alarm on-trigger scripts are compiled via `ScriptCompiler.TryCompile`, which performs a real Roslyn type-checking compile (using `ScriptCompileSurface` from the Script Analysis component as the globals type) and an authoritative forbidden-API check (via `ScriptTrustValidator.FindViolations`). Scripts with compilation errors, type errors, or forbidden-API violations are rejected. This gate is authoritative — not advisory — meaning alias, `using static`, and `global::` bypasses are caught by semantic symbol resolution.
- **Alarm trigger references**: Alarm trigger definitions reference attributes that exist in the flattened configuration.
- **Script trigger references**: Script triggers (value change, conditional) reference attributes that exist in the flattened configuration.
- **Expression-trigger syntax/trust check**: Each Expression-triggered script/alarm has its boolean expression run through `ValidationService.CheckExpressionSyntax` — a `ScriptTrustValidator.FindViolations` forbidden-API verdict followed by a real Roslyn compile against `TriggerCompileSurface`. Both stages report **all** findings (every forbidden API, every compile error), not just the first, so an operator fixing a multi-error expression sees the full list in one round-trip. The verdict is memoised in the surface-keyed `ScriptCompileVerdictCache`, so an unchanged expression compiles once per process.
- **Read-only skip (`validateScriptCompilation: false`)**: The `Validate` flag that read-only staleness/comparison callers (DeploymentManager's `FlatteningPipeline` on its comparison/probe paths) pass to skip the expensive Roslyn script-body compile now **also** skips the Expression-trigger syntax/compile check — the two compile-bearing stages. The cheap structural checks (blank-expression, attribute-reference scan) still run, and the deploy gate keeps the default (`true`), so it remains the authoritative check for both stages.
- **Data connection binding completeness**: Every attribute with a data source reference has a data connection binding assigned on the instance, and the bound data connection name exists as a defined connection at the instance's site.
- **Exception**: Validation does **not** verify that data source relative paths resolve to real tags on physical devices — that is a runtime concern.
+7 -3
View File
@@ -26,7 +26,7 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
- Validate `manifest.json` on upload: format version gating, SHA-256 content hash verification.
- Manage in-memory `BundleSession` objects: 30-minute TTL, 3-strike passphrase lockout per session.
- Compute a per-artifact diff between bundle contents and the target environment, classifying each artifact as Identical, Modified, New, a Blocker (import-stopping), or a Warning (advisory, non-blocking).
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running multi-pass semantic validation before committing: a **script trust gate** (`ScriptTrustValidator.FindViolations` over every non-Skip template / shared / ApiMethod body — bundle import is the fifth script-trust call site, so forbidden-API scripts are rejected at import review, not deferred to runtime), then a minimal name-resolution scan over the merged target (ApiMethod findings fail fast on unresolved SharedScript / ExternalSystem identifiers; template-script findings are advisory warnings), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
- Apply user-supplied conflict resolutions (Add, Overwrite, Skip, Rename) in a single EF transaction, running multi-pass semantic validation before committing: a **script trust gate** (`ScriptTrustValidator.FindViolations` over every non-Skip executable C# surface a bundle carries — template / shared / ApiMethod script bodies, template script **and** alarm **Expression-trigger** bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`); bundle import is the fifth script-trust call site, so forbidden-API scripts are rejected at import review, not deferred to runtime — all these surfaces hard-block uniformly, since the trust gate's semantic verdict is authoritative with no false-positive channel, and the advisory-vs-hard severity split is a property of the name-resolution heuristic only), then a minimal name-resolution scan over the merged target (ApiMethod findings fail fast on unresolved SharedScript / ExternalSystem identifiers; template-script findings are advisory warnings), then the full `SemanticValidator` from `ZB.MOM.WW.ScadaBridge.TemplateEngine` over each imported template's per-template `FlattenedConfiguration`.
- Emit `BundleExported`, `BundleImported`, `BundleImportFailed`, `UnencryptedBundleExport`, `BundleImportUnlockFailed`, `BundleImportAlarmScriptUnresolved`, `BundleImportCompositionUnresolved`, and `BundleImportBaseTemplateUnresolved` audit events via `IAuditService`.
- Thread a `BundleImportId` correlation GUID through every per-entity `AuditLogEntry` written during `ApplyAsync` via a scoped `IAuditCorrelationContext`.
- Enforce `RequireDesign` on export and `RequireAdmin` on import both at the Razor page layer and inside the service entrypoints (defense in depth).
@@ -133,7 +133,7 @@ flowchart TD
class T muted
```
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles — even when a bundle carries site-scoped config (T18), Transport only writes the central configuration database, never the site clusters. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session, and a `MaxConcurrentImportSessions` cap (default 8; arch-review 05 Task 24 — startup-validated as `> 0` by `TransportOptionsValidator`, since a zero/negative cap would reject every import session forever) bounding the decrypted-content footprint since each open session pins a fully-decrypted bundle in memory.
**Preview→apply window (recorded decision).** The diff/preview an operator reviews is a point-in-time snapshot; there is **no optimistic concurrency** between preview and apply. If the target config changes in the window between `PreviewAsync` and `ApplyAsync` (another admin edits a template, say), the apply proceeds against the current state — the resolutions are keyed by `(EntityType, Name)`, so they still target the right rows, but the operator may not have seen the latest state in the diff. This is accepted for v1 (single-operator import is the expected workflow, and the terminal Confirm step already gates a typo-resistant environment-name entry). The bundle-format `version` fields are reserved for a future optimistic-concurrency check but are not yet enforced (Underdeveloped 5).
@@ -233,7 +233,11 @@ Bundle references that cannot be satisfied in either the bundle or the target DB
**Blocker-scan heuristic boundaries.** The scanner walks `TemplateScript.Code`, `TemplateAttribute.Value`, and `ApiMethod.Script` looking for top-level `Identifier(` or `Identifier.` tokens. To keep the heuristic usable on real script bodies it (a) skips identifiers preceded by `.` (member access — `obj.Method()` does not flag `Method`); (b) does NOT scan `TemplateAttribute.DataSourceReference` (an OPC UA address path, never script source); (c) excludes names **declared locally in the body** — every `LocalFunctionStatement` / `MethodDeclaration` identifier is collected via a Roslyn script-kind parse, so a script's own helper (`decimal ComputeRate(...) => …; return ComputeRate(x);`) is not mistaken for a missing SharedScript; and (d) filters out a `KnownNonReferenceNames` denylist of .NET stdlib types (`Convert`, `DateTimeOffset`, `Regex`, `StringBuilder`, `Parse`, LINQ operators, …), ScadaBridge runtime API roots (`Notify`, `Database`, `ExternalSystem`, `Scripts`, `Instance`, `Parameters`, `Attributes`, `Route`, …), and common SQL keywords that appear inside string literals (`COUNT`, `SELECT`, `FROM`, `HAVING`, `VALUES`, …). Both the diff-step `DetectBlockersAsync` and the Apply-time `RunSemanticValidationAsync` Pass 1 share this filter, so the diff preview and the Apply gate agree.
**Severity split — template-script findings are advisory, ApiMethod findings block.** A residual unresolved identifier (one the heuristic could not filter) is classified by its **origin**. Findings from **template scripts** and **attribute default expressions** are non-blocking **warnings**: they surface as `ConflictKind.Warning` rows in the preview and ride on `ImportResult.Warnings` after a successful apply — the import proceeds because the authoritative design-time **deploy gate re-validates** the script with a real compile (`ScriptCompiler.TryCompile`), so a heuristic false positive here must never stop a valid import. Findings from **ApiMethod scripts** remain **hard errors** (`ConflictKind.Blocker` in preview; `SemanticValidationException` at apply) because inbound API methods have no downstream design-time gate — the import review is their only pre-runtime check. The split is applied identically in `DetectBlockersAsync` and `RunSemanticValidationAsync` Pass 1 so preview and apply agree.
**Severity split — template-script findings are advisory, ApiMethod findings block.** A residual unresolved identifier (one the heuristic could not filter) is classified by its **origin**. Findings from **template scripts** and **attribute default expressions** are non-blocking **warnings**: they surface as `ConflictKind.Warning` rows in the preview and ride on `ImportResult.Warnings` after a successful apply — the import proceeds because the authoritative design-time **deploy gate re-validates** the script with a real compile (`ScriptCompiler.TryCompile`), so a heuristic false positive here must never stop a valid import. Findings from **ApiMethod scripts** remain **hard errors** (`ConflictKind.Blocker` in preview; `SemanticValidationException` at apply) because inbound API methods have no downstream design-time gate — the import review is their only pre-runtime check. The split is applied identically in `DetectBlockersAsync` and `RunSemanticValidationAsync` Pass 1 so preview and apply agree. (This advisory-vs-hard split is a property of the name-resolution **heuristic** only — it is false-positive-prone; the **script trust gate** above has no false-positive channel, so its verdict is a hard error for every gated surface, template trigger expressions and instance alarm-override trigger expressions included.)
**Locked-member override warnings (advisory).** An instance override — attribute (`FlatteningService` drop), alarm, or native-alarm-source — that targets a **locked** template member is silently inert: the flattener ignores it. To stop that being invisible, a best-effort scan (`CollectLockedOverrideWarnings`) surfaces each such override as a `ConflictKind.Warning` preview row and on `ImportResult.Warnings` after apply, matching locked members by **direct name** (own + inherited placeholder rows both carry the `IsLocked` flag). It never blocks the import and never changes what is written — the override row is still persisted verbatim (bundle fidelity); the flattener remains the behavioural authority, and composed path-qualified members or derived-shadow (`LockedInDerived`) locks a placeholder row does not reflect emit no warning (never a false positive — the ManagementActor and flattener stay the enforcement points).
**Script-artifact change bus (post-commit).** After a successful apply the importer publishes one `ScriptArtifactsChanged` per script-bearing kind (ApiMethod / SharedScript / Template) for **every non-Skip resolution — Overwrite, Rename, AND Add** — because an artifact deleted on the target and re-imported as `Add` under the same name can still leave a stale compiled-handler/`_knownBadMethods` entry on a node; over-approximation is safe (the bus is advisory, at-least-once, and consumers self-heal). The **subscriber** side (Inbound API cache invalidation wiring) lives with the Inbound API component — see PLAN-R2-06.
**Step 5 — Confirm.** Final summary plus a "N instances will become stale" warning enumerating affected instances (a real count as of M8 — see "Stale-Instance Signaling"). User types the source environment name to confirm (typo-resistant gate at the prod boundary).
@@ -5,14 +5,25 @@ using System.Text;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// <summary>
/// Process-wide cache of script compile verdicts, keyed on a SHA-256 hash of the
/// script code. The verdict (clean/compiles, or the name-free error text) is a pure
/// function of the code plus the compile-time-static <c>ScriptTrustPolicy</c> and
/// globals surface, so it is sound to memoise it across the whole process: an
/// Process-wide cache of script compile verdicts, keyed on the pair
/// (globals surface, SHA-256 hash of the script code). The verdict (clean/compiles,
/// or the name-free error text) is a pure function of the code plus the
/// compile-time-static <c>ScriptTrustPolicy</c> AND the globals surface it was
/// compiled against, so it is sound to memoise it across the whole process: an
/// unchanged script runs the forbidden-API analysis and the Roslyn compile exactly
/// once, removing both the repeat CPU cost and the repeat compiled-assembly load.
///
/// <para>
/// The globals surface is part of the key because a verdict is NOT interchangeable
/// across surfaces: a trigger expression that compiles clean against
/// <c>TriggerCompileSurface</c> is not vetted for a <c>ScriptCompileSurface</c>
/// script body (different globals resolve different identifiers), so a code-only key
/// could return a stale "clean" for code never compiled against the caller's surface.
/// Keeping the surface in the key makes that cross-surface reuse structurally
/// impossible even as more compile surfaces gain cache entries.
/// </para>
///
/// <para>
/// The stored error is deliberately <em>name-free</em> — the caller's script name is
/// formatted into the returned message at read time, so two callers submitting the
/// same bad code each see their own name in the failure text while sharing one verdict.
@@ -40,17 +51,22 @@ public static class ScriptCompileVerdictCache
public static int Count => Cache.Count;
/// <summary>
/// Returns the cached verdict for <paramref name="code"/>, or computes it via
/// <paramref name="factory"/> and caches it on a miss. A hit increments
/// <see cref="Hits"/>. The verdict's error text must be name-free — the caller
/// formats the script name in on return.
/// Returns the cached verdict for the pair (<paramref name="surface"/>,
/// <paramref name="code"/>), or computes it via <paramref name="factory"/> and
/// caches it on a miss. A hit increments <see cref="Hits"/>. The verdict's error
/// text must be name-free — the caller formats the script name in on return.
/// </summary>
/// <param name="surface">
/// The globals-surface discriminator (e.g. <c>nameof(ScriptCompileSurface)</c> or
/// <c>nameof(TriggerCompileSurface)</c>). Part of the key so a verdict computed
/// against one surface is never reused for another.
/// </param>
/// <param name="code">The script source code to look up (hashed to form the cache key).</param>
/// <param name="factory">Computes the verdict on a cache miss.</param>
/// <returns>The cached or newly computed compile verdict.</returns>
public static (bool Ok, string? Error) GetOrAdd(string code, Func<(bool Ok, string? Error)> factory)
public static (bool Ok, string? Error) GetOrAdd(string surface, string code, Func<(bool Ok, string? Error)> factory)
{
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
var key = surface + ":" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
if (Cache.TryGetValue(key, out var verdict))
{
@@ -39,7 +39,7 @@ public class ScriptCompiler
// globals surface are compile-time static), so memoise it process-wide: an
// unchanged script compiles exactly once per process. The cached error is
// name-free — the script name is formatted in below at read time.
var (ok, error) = ScriptCompileVerdictCache.GetOrAdd(code, () =>
var (ok, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(ScriptCompileSurface), code, () =>
{
// Authoritative forbidden-API verdict first — a security violation must
// gate the script regardless of whether it otherwise compiles. Report
@@ -97,11 +97,16 @@ public class ValidationService
/// </param>
/// <param name="validateScriptCompilation">
/// <c>true</c> (default) runs the authoritative Roslyn <see cref="ValidateScriptCompilation"/>
/// stage — the single most expensive check (a non-collectible assembly load per script).
/// <c>false</c> skips ONLY that stage; every other structural/semantic check still runs.
/// Read-only staleness/comparison callers (DeploymentManager's FlatteningPipeline on its
/// comparison/probe paths) pass <c>false</c> because they need only the flattened config and
/// revision hash, not a compile. The deploy gate keeps the default (<c>true</c>).
/// stage — the single most expensive check (a non-collectible assembly load per script)
/// AND the Expression-trigger syntax/compile check
/// (<see cref="CheckExpressionSyntax"/>, a forbidden-API verdict plus a real compile of every
/// Expression-triggered script/alarm body against <see cref="TriggerCompileSurface"/>).
/// <c>false</c> skips ONLY those two compile stages; every other structural/semantic check
/// still runs — including, for Expression triggers, the blank-expression check and the
/// cheap attribute-reference scan. Read-only staleness/comparison callers (DeploymentManager's
/// FlatteningPipeline on its comparison/probe paths) pass <c>false</c> because they need only
/// the flattened config and revision hash, not a compile. The deploy gate keeps the default
/// (<c>true</c>) — it is the authoritative check for both stages.
/// </param>
/// <returns>A merged <see cref="ValidationResult"/> aggregating all pipeline stage outcomes.</returns>
public ValidationResult Validate(
@@ -128,7 +133,7 @@ public class ValidationService
validateScriptCompilation ? ValidateScriptCompilation(configuration) : ValidationResult.Success(),
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateExpressionTriggers(configuration, validateScriptCompilation),
ValidateConnectionBindingCompleteness(configuration, enforceConnectionBindings, siteConnectionNames),
ValidateSchemaReferences(configuration, sharedScripts, resolveSchemaRef),
_semanticValidator.Validate(configuration, sharedScripts, alarmCapableConnectionNames)
@@ -366,8 +371,16 @@ public class ValidationService
/// </list>
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <param name="validateExpressionSyntax">
/// <c>true</c> (default) runs the authoritative <see cref="CheckExpressionSyntax"/>
/// forbidden-API + compile stage for each Expression trigger. <c>false</c> skips ONLY
/// that stage — the blank-expression check and the attribute-reference scan still run —
/// so read-only staleness/comparison callers do not pay the per-expression compile.
/// </param>
/// <returns>A <see cref="ValidationResult"/> with errors and warnings from all expression trigger checks.</returns>
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
public static ValidationResult ValidateExpressionTriggers(
FlattenedConfiguration configuration,
bool validateExpressionSyntax = true)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
@@ -382,7 +395,7 @@ public class ValidationService
CheckExpressionTrigger(
ValidationCategory.ScriptTriggerReference, "script",
script.CanonicalName, script.TriggerConfiguration,
attributeNames, errors, warnings);
attributeNames, errors, warnings, validateExpressionSyntax);
}
foreach (var alarm in configuration.Alarms)
@@ -393,7 +406,7 @@ public class ValidationService
CheckExpressionTrigger(
ValidationCategory.AlarmTriggerReference, "alarm",
alarm.CanonicalName, alarm.TriggerConfiguration,
attributeNames, errors, warnings);
attributeNames, errors, warnings, validateExpressionSyntax);
}
return new ValidationResult { Errors = errors, Warnings = warnings };
@@ -424,7 +437,8 @@ public class ValidationService
string? triggerConfigJson,
HashSet<string> attributeNames,
List<ValidationEntry> errors,
List<ValidationEntry> warnings)
List<ValidationEntry> warnings,
bool validateExpressionSyntax = true)
{
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
var strict = IsStrictAnalysis(triggerConfigJson);
@@ -443,6 +457,13 @@ public class ValidationService
return;
}
// The syntax/compile stage is the expensive one (a forbidden-API verdict + a
// real Roslyn compile of the expression). Read-only staleness/comparison
// callers pass validateExpressionSyntax: false to skip ONLY this stage; the
// blank-expression check above and the attribute-reference scan below still run.
// The deploy gate keeps the default (true) — the authoritative check.
if (validateExpressionSyntax)
{
var syntaxError = CheckExpressionSyntax(expression);
if (syntaxError != null)
{
@@ -450,6 +471,7 @@ public class ValidationService
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
entityName));
}
}
foreach (var attrName in ExtractAttributeReferences(expression))
{
@@ -530,17 +552,27 @@ public class ValidationService
/// <returns>A human-readable error message if the expression is invalid; <c>null</c> if well-formed.</returns>
internal static string? CheckExpressionSyntax(string expression)
{
// Authoritative forbidden-API verdict first.
// Memoise the verdict under the TRIGGER surface key (see ScriptCompileVerdictCache):
// the verdict is a pure function of expression + policy + TriggerCompileSurface, and
// it is the exact hot-path cost N1 flagged — every staleness sweep / import probe of
// an Expression-triggered config re-ran a full trust compilation + script compile.
var (_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () =>
{
// Authoritative forbidden-API verdict first. Report ALL violations, not
// just the first, so an operator fixing one forbidden API isn't surprised
// by a second on the next deploy attempt (mirrors ScriptCompiler.TryCompile).
var violations = ScriptTrustValidator.FindViolations(expression);
if (violations.Count > 0)
return $"uses forbidden API: {violations[0]}";
return (false, $"uses forbidden API: {string.Join("; ", violations)}");
// Real compile of the bare boolean expression against the trigger globals.
var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface));
if (errors.Count > 0)
return $"is not a valid expression: {errors[0]}";
return (false, $"is not a valid expression: {string.Join("; ", errors)}");
return null;
return (true, (string?)null);
});
return error;
}
/// <summary>
@@ -684,8 +684,9 @@ public sealed class BundleImporter : IBundleImporter
// its new name, but at preview time no resolution has been chosen yet, so
// the in-bundle name is the original DTO name — which is what an instance
// references. (Explicit rename remap is a D-wave apply-time concern.)
var targetTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
foreach (var t in targetTemplates)
{
targetTemplateNames.Add(t.Name);
}
@@ -883,6 +884,22 @@ public sealed class BundleImporter : IBundleImporter
}
}
// N4: advisory warning rows for instance overrides that target a LOCKED
// template member — the flattener drops them, so surface the inert row in the
// wizard (Kind: Warning, non-blocking). resolutionMap is null at preview time,
// so every instance is scanned.
foreach (var (instance, message) in CollectLockedOverrideWarnings(content, resolutionMap: null, targetTemplates))
{
blockers.Add(new ImportPreviewItem(
EntityType: "Instance",
Name: instance,
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Warning,
FieldDiffJson: null,
BlockerReason: message));
}
return blockers;
}
@@ -946,8 +963,11 @@ public sealed class BundleImporter : IBundleImporter
/// <summary>
/// Enumerates every executable C# surface a bundle carries that the
/// trust gate must vet: non-Skip template scripts + their Expression-trigger
/// bodies, template alarm Expression-trigger bodies, shared scripts, and
/// ApiMethod scripts. Expression triggers compile and execute at the site
/// bodies, template alarm Expression-trigger bodies, shared scripts,
/// ApiMethod scripts, AND instance alarm-override trigger expressions
/// (<c>InstanceAlarmOverrideDto.TriggerConfigurationOverride</c>). Expression
/// triggers — template alarms AND the instance overrides that replace them in
/// the flattened config — compile and execute at the site
/// (`TriggerExpressionGlobals`), so they are a genuine trust surface, not just
/// script bodies. A null <paramref name="resolutionMap"/> (preview time, before
/// resolutions are chosen) gates everything. Yields <c>(kind, entityName,
@@ -998,6 +1018,25 @@ public sealed class BundleImporter : IBundleImporter
yield return ("ApiMethod", m.Name, m.Name, m.Script);
}
}
foreach (var i in content.Instances)
{
if (IsSkipResolution(resolutionMap, "Instance", i.UniqueName)) continue;
foreach (var o in i.AlarmOverrides)
{
// The DTO carries no trigger type (the type lives on the template
// alarm), so gate ANY override config carrying an {"expression":...}
// string body: if the overridden alarm is not Expression-triggered
// the body never executes, but vetting it anyway is fail-safe — the
// structured (HiLo/threshold) configs have no "expression" key, so
// there is no false-positive channel.
var expr = ExtractExpressionBody(o.TriggerConfigurationOverride);
if (!string.IsNullOrEmpty(expr))
{
yield return ("Instance", i.UniqueName,
$"{o.AlarmCanonicalName} (alarm trigger override expression)", expr);
}
}
}
}
/// <summary>
@@ -1009,8 +1048,24 @@ public sealed class BundleImporter : IBundleImporter
private static string? ExtractTriggerExpression(string? triggerType, string? triggerConfigJson)
{
if (triggerType is null
|| !triggerType.Equals("Expression", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(triggerConfigJson))
|| !triggerType.Equals("Expression", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return ExtractExpressionBody(triggerConfigJson);
}
/// <summary>
/// Extracts the C# boolean expression from an <c>{"expression":"…"}</c> trigger
/// configuration, WITHOUT a trigger-type guard — used where the caller has no
/// trigger type to check (an instance alarm override; the type lives on the
/// template alarm). A config carrying no <c>"expression"</c> string key (the
/// structured HiLo/threshold shapes) or malformed JSON yields null — nothing to
/// gate.
/// </summary>
private static string? ExtractExpressionBody(string? triggerConfigJson)
{
if (string.IsNullOrWhiteSpace(triggerConfigJson))
{
return null;
}
@@ -1037,6 +1092,85 @@ public sealed class BundleImporter : IBundleImporter
=> resolutionMap != null
&& ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;
/// <summary>
/// Best-effort scan for instance overrides that target a LOCKED template member.
/// The flattener drops such overrides (an override on a locked attribute / alarm /
/// native-alarm-source never takes effect), so the bundle carries an inert row —
/// this surfaces an advisory warning so the operator learns the config won't apply,
/// WITHOUT blocking the import or changing what gets written (bundle fidelity keeps
/// the row; the flattener stays the behavioural authority).
/// <para>
/// Matches locked members by DIRECT name only — own + inherited placeholder rows
/// both carry the <c>IsLocked</c> flag, so a lock on either matches. Overrides
/// addressing composed path-qualified members (<c>y1.z.Val</c>) or derived-shadow
/// locks (<c>LockedInDerived</c> on a base the placeholder row doesn't reflect) may
/// not match a direct row — an unmatched name emits NO warning (never a false
/// positive; the ManagementActor and flattener remain the enforcement points). The
/// instance's template is resolved from the bundle DTO (the version about to be
/// written) first, else the pre-existing target template.
/// </para>
/// </summary>
private static IReadOnlyList<(string Instance, string Message)> CollectLockedOverrideWarnings(
BundleContentDto content,
Dictionary<(string, string), ImportResolution>? resolutionMap,
IReadOnlyList<Template> targetTemplates)
{
var warnings = new List<(string, string)>();
var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
foreach (var t in targetTemplates) targetByName[t.Name] = t;
foreach (var inst in content.Instances)
{
if (IsSkipResolution(resolutionMap, "Instance", inst.UniqueName)) continue;
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
HashSet<string> lockedAttrs;
HashSet<string> lockedAlarms;
HashSet<string> lockedSources;
if (bundleTemplates.TryGetValue(inst.TemplateName, out var dto))
{
lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
}
else if (targetByName.TryGetValue(inst.TemplateName, out var tgt))
{
lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
}
else
{
// Template unresolvable — a separate blocker/validation covers that.
continue;
}
foreach (var o in inst.AttributeOverrides)
{
if (lockedAttrs.Contains(o.AttributeName))
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "attribute", o.AttributeName)));
}
foreach (var o in inst.AlarmOverrides)
{
if (lockedAlarms.Contains(o.AlarmCanonicalName))
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "alarm", o.AlarmCanonicalName)));
}
foreach (var o in inst.NativeAlarmSourceOverrides)
{
if (lockedSources.Contains(o.SourceCanonicalName))
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "native-alarm-source", o.SourceCanonicalName)));
}
}
return warnings;
}
private static string LockedOverrideMessage(string unique, string kind, string name) =>
$"Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — " +
"the flattener ignores it, so this part of the bundle's instance config will never take effect.";
/// <summary>
/// Names that look like PascalCase references but are never user-defined
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
@@ -1204,7 +1338,7 @@ public sealed class BundleImporter : IBundleImporter
if (validationWarnings.Count > 0)
{
_logger?.LogWarning(
"Bundle import {BundleImportId}: {Count} advisory template-script reference warning(s): {Warnings}",
"Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}",
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
}
@@ -1767,12 +1901,15 @@ public sealed class BundleImporter : IBundleImporter
/// </summary>
/// <summary>
/// Publishes one <see cref="ScriptArtifactsChanged"/> per script-bearing
/// artifact kind (ApiMethod / SharedScript / Template) whose resolution was an
/// Overwrite or Rename, using the post-resolution (renamed) names. Adds are
/// excluded — nothing is cached under a brand-new name yet. Over-approximation is
/// safe (an Overwrite that actually fell through to Add just triggers a harmless
/// consumer re-check), which matches the bus's advisory, at-least-once contract.
/// Fully guarded: never throws (it runs after the transaction has committed).
/// artifact kind (ApiMethod / SharedScript / Template) for every non-Skip
/// resolution — Overwrite, Rename, AND Add — using the post-resolution (renamed)
/// names. An Add is NOT "nothing cached yet": an artifact deleted on the target and
/// re-imported as Add under the same name can still have a stale
/// compiled-handler/<c>_knownBadMethods</c> entry on any node, so it must notify too.
/// Over-approximation stays safe (the bus is advisory, at-least-once; consumers
/// self-heal), which matches its stated contract. Only Skip resolutions are excluded
/// (nothing was written). Fully guarded: never throws (it runs after the transaction
/// has committed).
/// </summary>
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
{
@@ -1799,7 +1936,7 @@ public sealed class BundleImporter : IBundleImporter
{
var names = all
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
&& (r.Action is ResolutionAction.Overwrite or ResolutionAction.Rename))
&& r.Action is not ResolutionAction.Skip)
.Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
.Distinct(StringComparer.Ordinal)
.ToList();
@@ -4334,6 +4471,15 @@ public sealed class BundleImporter : IBundleImporter
// (Pass 0 emits only errors), so nothing surfacable is dropped.
if (errors.Count > 0) return (errors, warnings);
// N4: advisory warnings for instance overrides that target a LOCKED template
// member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
// still written (bundle fidelity) — the flattener is the behavioural authority.
var lockScanTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
foreach (var (_, message) in CollectLockedOverrideWarnings(content, resolutionMap, lockScanTemplates))
{
warnings.Add(message);
}
// ---- Pass 1: minimal name-resolution scan ----
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
@@ -48,6 +48,10 @@ public sealed class TransportOptionsValidator : OptionsValidatorBase<TransportOp
$"ScadaBridge:Transport:MaxUnlockAttemptsPerSession must be positive " +
$"(was {options.MaxUnlockAttemptsPerSession}); it caps failed passphrase attempts before a session locks.");
builder.RequireThat(options.MaxConcurrentImportSessions > 0,
$"ScadaBridge:Transport:MaxConcurrentImportSessions must be positive " +
$"(was {options.MaxConcurrentImportSessions}); a zero/negative cap rejects every import session forever.");
builder.RequireThat(options.MaxUnlockAttemptsPerIpPerHour > 0,
$"ScadaBridge:Transport:MaxUnlockAttemptsPerIpPerHour must be positive " +
$"(was {options.MaxUnlockAttemptsPerIpPerHour}); it caps unlock attempts per IP address per hour.");
@@ -149,6 +149,48 @@ public class ScriptCompilerTests
Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit()
{
ScriptCompileVerdictCache.Clear();
ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
var hitsBefore = ScriptCompileVerdictCache.Hits;
var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null");
Assert.Null(error);
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use
}
[Fact]
public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface()
{
// "Notify != null" resolves on ScriptCompileSurface (Notify is a script global,
// ScriptCompileSurface.cs:53) but NOT on TriggerCompileSurface — a shared
// code-only cache entry would wrongly report the trigger expression clean.
ScriptCompileVerdictCache.Clear();
Assert.True(new ScriptCompiler().TryCompile("Notify != null", "S").IsSuccess); // warms the SCRIPT-surface entry
var error = ValidationService.CheckExpressionSyntax("Notify != null");
Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural
}
[Fact]
public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll()
{
var error = ValidationService.CheckExpressionSyntax(
"System.IO.File.Exists(\"x\") && System.Diagnostics.Process.GetProcesses().Length > 0");
Assert.NotNull(error);
Assert.Contains("System.IO", error); // FAILS today: only violations[0] is surfaced
Assert.Contains("Process", error);
}
[Fact]
public void CheckExpressionSyntax_MultipleCompileErrors_ReportsAll()
{
var error = ValidationService.CheckExpressionSyntax("NoSuchThingA > 1 && NoSuchThingB < 2");
Assert.NotNull(error);
Assert.Contains("NoSuchThingA", error); // FAILS today: only errors[0] is surfaced
Assert.Contains("NoSuchThingB", error);
}
// --- Compile-verdict cache: unchanged code compiles once per process ---
[Fact]
@@ -163,6 +205,20 @@ public class ScriptCompilerTests
Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
}
[Fact]
public void GetOrAdd_SameCodeDifferentSurface_ComputesSeparateVerdicts()
{
ScriptCompileVerdictCache.Clear();
var a = ScriptCompileVerdictCache.GetOrAdd("SurfaceA", "return 1;", () => (true, null));
var hitsAfterA = ScriptCompileVerdictCache.Hits;
var b = ScriptCompileVerdictCache.GetOrAdd("SurfaceB", "return 1;", () => (false, "err"));
Assert.True(a.Ok);
Assert.False(b.Ok); // code-only key would return SurfaceA's verdict
Assert.Equal(hitsAfterA, ScriptCompileVerdictCache.Hits); // no false cross-surface hit
Assert.Equal(2, ScriptCompileVerdictCache.Count);
}
[Fact]
public void TryCompile_CachedFailure_FormatsNameForEachCaller()
{
@@ -462,4 +462,43 @@ public class ValidationServiceTests
Assert.DoesNotContain(strictResult.Errors, e => e.Category == ValidationCategory.AlarmTriggerReference);
Assert.DoesNotContain(strictResult.Warnings, w => w.Category == ValidationCategory.AlarmTriggerReference);
}
[Fact]
public void Validate_SkipCompilation_SkipsExpressionTriggerSyntaxCheck()
{
var config = ConfigWithExpressionTriggerScript("this is not C# ((");
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
Assert.DoesNotContain(gated.Errors, e => e.Message.Contains("failed validation")); // FAILS today
var full = new ValidationService().Validate(config);
Assert.Contains(full.Errors, e => e.Message.Contains("failed validation")); // deploy gate unchanged
}
[Fact]
public void Validate_SkipCompilation_StillChecksExpressionAttributeReferences()
{
// expression "Attributes[\"Ghost\"] != null" referencing a missing attribute:
// the reference scan is cheap string work and MUST survive the gate.
var config = ConfigWithExpressionTriggerScript("Attributes[\"Ghost\"] != null");
var gated = new ValidationService().Validate(config, validateScriptCompilation: false);
Assert.Contains(gated.Errors, e => e.Message.Contains("Ghost"));
}
private static FlattenedConfiguration ConfigWithExpressionTriggerScript(string expression)
{
var json = System.Text.Json.JsonSerializer.Serialize(new { expression });
return new FlattenedConfiguration
{
InstanceUniqueName = "Instance1",
Attributes = [new ResolvedAttribute { CanonicalName = "Temp", Value = "25", DataType = "Double" }],
Scripts =
[
new ResolvedScript
{
CanonicalName = "OnExpr",
TriggerType = "Expression",
TriggerConfiguration = json
}
]
};
}
}
@@ -1397,6 +1397,61 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
}
[Fact]
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
{
// ApiMethod imported as Add into a target where the name does not exist.
// Delete-then-reimport-as-Add is the N3 edge: a node can still hold a
// _knownBadMethods / compiled-handler entry under that very name, so Adds
// must notify too (over-approximation is safe per the bus contract).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.ApiMethods.Add(new ApiMethod("DelmiaRecipeDownload", "return 1;") { TimeoutSeconds = 30 });
await ctx.SaveChangesAsync();
}
Guid sessionId;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var selection = new ExportSelection(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
IncludeDependencies: false);
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
ms.Position = 0;
// Delete the target method so the resolution is a genuine Add.
ctx.ApiMethods.RemoveRange(await ctx.ApiMethods.ToListAsync());
await ctx.SaveChangesAsync();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
}
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("ApiMethod", "DelmiaRecipeDownload", ResolutionAction.Add, null) },
user: "bob");
}
var notification = Assert.Single(_artifactBus.Received,
n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod);
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
}
[Fact]
public async Task ApplyAsync_failed_apply_publishes_nothing()
{
@@ -1222,4 +1222,170 @@ public sealed class SiteInstanceImportTests : IDisposable
Assert.Equal(1, result.Added);
}
// ──────────────────────────────────────────────────────────────────────
// N4 (plan R2-05 T7): warn on overrides of LOCKED template members
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
{
// Template attribute "SetPoint" IsLocked=true; instance carries an
// InstanceAttributeOverride for "SetPoint" — the flattener drops it, so the
// import surfaces an advisory warning but the row IS still written.
await SeedLockedMemberClosureAsync(lockAttribute: true);
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: Array.Empty<ConnectionMapping>());
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
Assert.Contains(result.Warnings, w =>
w.Contains("SetPoint", StringComparison.Ordinal)
&& w.Contains("locked", StringComparison.OrdinalIgnoreCase));
// Behaviour unchanged: the override row IS still written (bundle fidelity).
await using (var verify = _provider.CreateAsyncScope())
{
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.True(await ctx.InstanceAttributeOverrides.AnyAsync(o => o.AttributeName == "SetPoint"));
}
}
[Fact]
public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
{
// Template native-alarm-source "NativeSrc" IsLocked=true; instance carries a
// NativeAlarmSourceOverride for it. The wizard shows a non-blocking Warning row.
await SeedLockedMemberClosureAsync(lockNativeSource: true);
var sessionId = await ExportAllSitesAndLoadAsync();
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
preview = await importer.PreviewAsync(sessionId);
}
Assert.Contains(preview.Items, b =>
b.Kind == ConflictKind.Warning && b.EntityType == "Instance"
&& b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
{
// Same shape, but "SetPoint" is NOT locked — no lock warning must appear.
await SeedLockedMemberClosureAsync(lockAttribute: false);
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: Array.Empty<ConnectionMapping>());
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
Assert.DoesNotContain(result.Warnings, w =>
w.Contains("locked", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose
/// overrides target the given members; <paramref name="lockAttribute"/> /
/// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag.
/// The attribute override is always present; the native-source override (and its
/// connection) are seeded only when a native source is involved.
/// </summary>
private async Task SeedLockedMemberClosureAsync(bool lockAttribute = false, bool lockNativeSource = false)
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template("Pump") { Description = "pump tpl" };
template.Attributes.Add(new TemplateAttribute("SetPoint") { Value = "0", IsLocked = lockAttribute });
if (lockNativeSource)
{
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("NativeSrc")
{
ConnectionName = "OpcUaPrimary",
SourceReference = "ns=3;s=Pump.Alarm",
IsLocked = true,
});
}
ctx.Templates.Add(template);
var site = new Site("Plant 1", "plant-1")
{
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
GrpcNodeAAddress = "10.0.0.1:8083",
GrpcNodeBAddress = "10.0.0.2:8083",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
DataConnection? conn = null;
if (lockNativeSource)
{
conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
FailoverRetryCount = 5,
};
ctx.DataConnections.Add(conn);
}
var instance = new Instance("Pump-01")
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("SetPoint") { OverrideValue = "42" });
if (lockNativeSource)
{
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
{
ConnectionNameOverride = "OpcUaPrimary",
SourceReferenceOverride = "ns=3;s=Pump.Alarm2",
});
}
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
}
}
@@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -122,6 +124,78 @@ public sealed class SemanticValidatorImportTests : IDisposable
return sessionId;
}
/// <summary>
/// Seeds a template + site + instance whose single alarm override carries the
/// given <paramref name="triggerConfigJson"/> as its
/// <c>TriggerConfigurationOverride</c>, so an export→load carries the instance
/// alarm-override trigger expression through the trust gate.
/// </summary>
private async Task SeedInstanceAlarmOverrideAsync(string triggerConfigJson)
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template("Pump") { Description = "pump tpl" };
ctx.Templates.Add(template);
var site = new Site("Plant 1", "plant-1")
{
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
GrpcNodeAAddress = "10.0.0.1:8083",
GrpcNodeBAddress = "10.0.0.2:8083",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var instance = new Instance("Pump-01")
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm")
{
TriggerConfigurationOverride = triggerConfigJson,
});
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
}
/// <summary>Exports every seeded site (+ template) closure into a bundle and loads it.</summary>
private async Task<Guid> ExportSitesAndLoadAsync()
{
byte[] bundleBytes;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true,
SiteIds: siteIds);
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
bundleBytes = ms.ToArray();
}
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
using var input = new MemoryStream(bundleBytes, writable: false);
return (await importer.LoadAsync(input, passphrase: null)).SessionId;
}
}
[Fact]
public async Task TemplateScript_with_unresolved_reference_warns_but_does_not_block_import()
{
@@ -406,6 +480,111 @@ public sealed class SemanticValidatorImportTests : IDisposable
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
}
[Fact]
public async Task Apply_InstanceAlarmOverrideExpressionWithForbiddenApi_HardBlocks()
{
// N5 — an instance alarm override's TriggerConfigurationOverride replaces the
// template alarm's trigger config in the flattened config and compiles/executes
// at the site, so a forbidden API in it must be caught at import review — the
// same hard-block severity as every other trust-gate surface.
await SeedInstanceAlarmOverrideAsync(
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
var sessionId = await ExportSitesAndLoadAsync();
SemanticValidationException ex = default!;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Add, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
user: "bob"));
}
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, e =>
e.Contains("Process", StringComparison.Ordinal)
&& e.Contains("trigger override", StringComparison.Ordinal));
// Rollback contract: nothing from THIS import was written (the seed rows
// pre-exist, but no new import ran — the throw is pre-write at Pass 0).
}
[Fact]
public async Task Preview_InstanceAlarmOverrideExpressionWithForbiddenApi_SurfacesBlocker()
{
await SeedInstanceAlarmOverrideAsync(
"{\"expression\":\"System.Diagnostics.Process.GetProcesses().Length > 0\"}");
var sessionId = await ExportSitesAndLoadAsync();
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
preview = await importer.PreviewAsync(sessionId);
}
Assert.Contains(preview.Items, b =>
b.Kind == ConflictKind.Blocker && b.EntityType == "Instance"
&& b.BlockerReason!.Contains("trust violation", StringComparison.Ordinal));
}
[Fact]
public async Task Apply_InstanceAlarmOverrideExpression_Clean_Imports()
{
// A clean expression against the trigger surface imports without hard-block;
// the override row is persisted verbatim (bundle fidelity).
await SeedInstanceAlarmOverrideAsync(
"{\"expression\":\"Attributes[\\\"Temp\\\"] != null\"}");
var sessionId = await ExportSitesAndLoadAsync();
// Wipe so the apply exercises a create; re-create the template the instance binds to.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
ctx.Instances.RemoveRange(ctx.Instances);
ctx.Sites.RemoveRange(ctx.Sites);
ctx.Templates.RemoveRange(ctx.Templates);
await ctx.SaveChangesAsync();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: Array.Empty<ConnectionMapping>());
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
await importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
user: "bob", ct: CancellationToken.None, nameMap: nameMap);
}
await using (var verify = _provider.CreateAsyncScope())
{
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var ovr = await ctx.InstanceAlarmOverrides.SingleAsync();
Assert.Equal("HiAlarm", ovr.AlarmCanonicalName);
Assert.Contains("Attributes", ovr.TriggerConfigurationOverride!);
}
}
[Fact]
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
{
@@ -38,6 +38,22 @@ public class TransportOptionsValidatorTests
Assert.Contains("MaxBundleSizeMb", result.FailureMessage);
}
[Fact]
public void ZeroMaxConcurrentImportSessions_IsRejected()
{
var result = Validate(new TransportOptions { MaxConcurrentImportSessions = 0 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage); // FAILS today: validates clean
}
[Fact]
public void NegativeMaxConcurrentImportSessions_IsRejected()
{
var result = Validate(new TransportOptions { MaxConcurrentImportSessions = -1 });
Assert.True(result.Failed);
Assert.Contains("MaxConcurrentImportSessions", result.FailureMessage);
}
[Fact]
public void ZeroPbkdf2Iterations_IsRejected()
{