From 69a2017000a9f0060a35832fa410886b36dbfc93 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 15:02:49 -0500 Subject: [PATCH] docs(components): reference docs for ScriptAnalysis, KpiHistory and DelmiaNotifier (ScadaBridge#72) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV --- CLAUDE.md | 2 +- README.md | 2 +- docs/components/DelmiaNotifier.md | 125 ++++++++++++++++++++++++++++++ docs/components/KpiHistory.md | 114 +++++++++++++++++++++++++++ docs/components/README.md | 9 ++- docs/components/ScriptAnalysis.md | 117 ++++++++++++++++++++++++++++ 6 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 docs/components/DelmiaNotifier.md create mode 100644 docs/components/KpiHistory.md create mode 100644 docs/components/ScriptAnalysis.md diff --git a/CLAUDE.md b/CLAUDE.md index 8ce4201b..21a8476f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Other peers in the `scadaproj` family (see `scadaproj/CLAUDE.md` for details): ` - Each component document follows a consistent structure: Purpose, Location, Responsibilities, detailed design sections, Dependencies, and Interactions. - The README.md component table must stay in sync with actual component documents. When a component is added, removed, or renamed, update the table. - Cross-component references in Dependencies and Interactions sections must be kept accurate across all documents. When a component's role changes, update references in all affected documents. -- Exception: DelmiaNotifier (#27) is an external client tool, not a cluster component; its spec is the project README (`src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`) plus `docs/plans/2026-06-26-delmia-recipe-notifier-design.md` — there is intentionally no `Component-DelmiaNotifier.md`. +- Exception: DelmiaNotifier (#27) is an external client tool, not a cluster component; its spec is the project README (`src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md`) plus `docs/plans/2026-06-26-delmia-recipe-notifier-design.md` — there is intentionally no `Component-DelmiaNotifier.md` requirements document (its reference doc is `docs/components/DelmiaNotifier.md`). ## Refinement Process diff --git a/README.md b/README.md index 0d87da77..5c2e8236 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA ### Component Reference Documentation -The design documents above are the **specs** (what each component does and why). For developer **reference** docs that describe how the shipped code works — with real code examples, drawn from `src/` and written to the [Style Guide](StyleGuide.md) — see [docs/components/](docs/components/). Reference docs exist for 24 of the 27 components (plus the shared TreeView); ScriptAnalysis (#25), KpiHistory (#26), and DelmiaNotifier (#27) are pending — tracked in [docs/plans/2026-07-08-deferred-work-register.md](docs/plans/2026-07-08-deferred-work-register.md). Indexed in [docs/components/README.md](docs/components/README.md). +The design documents above are the **specs** (what each component does and why). For developer **reference** docs that describe how the shipped code works — with real code examples, drawn from `src/` and written to the [Style Guide](StyleGuide.md) — see [docs/components/](docs/components/). Reference docs exist for all 27 components (plus the shared TreeView). Indexed in [docs/components/README.md](docs/components/README.md). ### Reference Documentation diff --git a/docs/components/DelmiaNotifier.md b/docs/components/DelmiaNotifier.md new file mode 100644 index 00000000..8ad3c5c7 --- /dev/null +++ b/docs/components/DelmiaNotifier.md @@ -0,0 +1,125 @@ +# Delmia Notifier + +Delmia Notifier (#27) is a compact console executable that DELMIA Apriso shells out to on each recipe or NC-program download. It POSTs the notification to the ScadaBridge Inbound API method `DelmiaRecipeDownload` and reports the result in the legacy `WWNotifier` `YES` / `NO` stdout contract. It is an external client tool, not a cluster component: it never joins the cluster and has no actors or database. + +## Overview + +The project lives in `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/`. Its `csproj` sets `AssemblyName` to `WWNotifier`, `PublishAot` to `true` and `InvariantGlobalization` to `true`, so the published `WWNotifier.exe` is a file-for-file replacement for the legacy tool. No `RuntimeIdentifier` is fixed in the project; the runtime artifact is produced with `dotnet publish -c Release -r win-x64` (Native AOT does not cross-compile from macOS or Linux). `appsettings.json` is copied to the output directory. + +Per invocation the call path is: + +```text +DELMIA → WWNotifier.exe → POST {baseUrl}/api/DelmiaRecipeDownload (X-API-Key: ) + ← { "Result": true/false, "ResultText": "..." } + ← stdout: YES | NO\n · exit: 0 | -1 +``` + +The spec for this component is the project `README.md` plus `docs/plans/2026-06-26-delmia-recipe-notifier-design.md`; there is no `Component-DelmiaNotifier.md` requirements document. The inbound method it targets is described in `docs/api-specs/Delmia-Recipe-Download-API.md`. + +## Key Concepts + +### Output contract + +`Reporter.Report` writes the only stdout Delmia parses. Success writes `YES\n` and returns `0`. Failure writes `NO\n` followed by the reason (no trailing newline) and returns `-1`. All diagnostics go to stderr and, optionally, a log file, never to stdout. + +### Pre-execution-only failover + +`Notifier.RunAsync` walks the configured base URLs in order and moves to the next URL only when the previous attempt provably did not execute. The sender classifies each attempt as one of four `AttemptKind` values: + +| Kind | Raised when | Loop behaviour | +|---|---|---| +| `ConnectFailed` | `HttpRequestException` with `HttpRequestError.NameResolutionError`, or an inner `SocketException` with `ConnectionRefused` | Try the next URL | +| `StandbyRejected` | HTTP 503 whose `X-ScadaBridge-Standby` header is exactly `1` | Try the next URL | +| `OutcomeUnknown` | Any other `HttpRequestException`, or a timeout (`OperationCanceledException` not caused by the caller's token) | Stop; return `NO` with `OUTCOME_UNKNOWN: the request may have been applied; verify before retrying.` | +| `Connected` | Any HTTP response not matched above | Stop; the node's answer is final | + +For a `Connected` outcome with a 2xx status, `Body.Result == true` yields `YES` (reason is `ResultText`); a false or unparseable body yields `NO` with `ResultText`, or `request rejected` if that is empty. A non-2xx status yields `NO` with `HTTP `. If every URL fails pre-execution the reason is `all URLs unreachable: `. A generic 503 without the standby marker is final. + +### API key resolution + +`ConfigLoader.ResolveApiKey` prefers the `SCADABRIDGE_API_KEY` environment variable when non-blank, then `ScadaBridge:ApiKey` from `appsettings.json`; both are trimmed. The chosen source name is logged (`environment (SCADABRIDGE_API_KEY)` or `appsettings.json (ScadaBridge:ApiKey)`), never the key. The tracked `appsettings.json` must keep `ApiKey` as `""`. + +## Architecture + +| File | Role | +|---|---| +| `Program.cs` | `Main`: load config, parse args, resolve URLs and key, build the `HttpClient` with `TimeoutSeconds`, run `Notifier`, report. Any exception becomes `NO` + `unexpected error: ...`. A private `LoggingRecipeSender` decorator logs one line per attempt. | +| `ArgParser.cs` | Hand-rolled, reflection-free parser for the legacy flags. | +| `ConfigLoader.cs` | Deserialises `appsettings.json` (next to the exe, `AppContext.BaseDirectory`) via the source-generated `NotifierJsonContext`; validates every `BaseUrls` entry; splits the list; resolves the key. | +| `Notifier.cs` | The failover loop and `NotifyResult` mapping. | +| `IRecipeSender.cs` / `HttpRecipeSender.cs` | The send seam and its HTTP implementation. | +| `Reporter.cs` | Legacy stdout and exit-code mapping. | +| `DiagLog.cs` | Best-effort UTC-timestamped diagnostics. | +| `RecipeDownload.cs` / `RecipeDownloadResult.cs` | Request and response DTOs. | + +### Command-line flags + +Each flag takes exactly one value. A flag without a value, or an unknown flag, fails the parse. Parsing failures produce `NO` + reason and exit `-1` with no HTTP attempt. + +| Short | Long | Required | Payload field | +|---|---|---|---| +| `-m` | `--machine` | yes | `MachineCode` | +| `-d` | `--downloadpath` | yes | `DownloadPath` | +| `-w` | `--workorder` | yes | `WorkOrderNumber` | +| `-p` | `--partnumber` | yes | `PartNumber` | +| `-s` | `--seqop` | no | `JobStepNumber` | +| `-u` | `--username` | no | `Username` | + +### `HttpRecipeSender` + +The URL is `baseUrl.TrimEnd('/') + "/api/DelmiaRecipeDownload"`. The JSON body is serialised through the source-generated context, the `X-API-Key` header is attached, and the response body is read and parsed only for 2xx statuses. A `JsonException` on a 2xx body yields a null body, which the loop treats as a failure. + +### `DiagLog` + +Each line is `yyyy-MM-ddTHH:mm:ss.fffZ `, written to stderr and appended to `LogPath` when set (relative paths resolve against the exe directory, directories are created on demand). File errors are swallowed so the `YES`/`NO` contract is unaffected. + +## Usage + +```powershell +$env:SCADABRIDGE_API_KEY = "sbk_..." +.\WWNotifier.exe -m Z28061 -d "C:\recipes\job.nc" -w WO12345 -p PN-7788 -s 0100 -u operator1 +``` + +The README documents a Docker-rig verification recipe (`docker/seed-delmia-sim.sh`, `docker/seed-delmia-api.sh`, `docker/verify-delmia-notifier.sh`) covering active-first, standby-first, bad-key and failover cases. + +## Configuration + +`appsettings.json` sits next to the executable; the section is `ScadaBridge`. + +| Key | Default | Description | +|---|---|---| +| `BaseUrls` | none | Comma-separated base URLs (no path). Each entry must be an absolute HTTP(S) URI with a host, or config load fails. Empty yields `no BaseUrls configured`. | +| `TimeoutSeconds` | `30` | Per-attempt `HttpClient` timeout. | +| `LogPath` | none | Optional diagnostic log file; omit for stderr only. | +| `ApiKey` | none | Fallback key source; `SCADABRIDGE_API_KEY` wins. | + +## Dependencies & Interactions + +- [Inbound API (#14)](./InboundAPI.md) — hosts the `DelmiaRecipeDownload` method the notifier calls; the standby marker `X-ScadaBridge-Standby: 1` on a 503 is a server-side contract the notifier relies on. +- [Site Runtime (#3)](./SiteRuntime.md) — the inbound method routes to the machine instance's script, which performs the Delmia receiver handshake. +- The project has no project references to other ScadaBridge assemblies; the test project accesses internals through `InternalsVisibleTo`. + +## Troubleshooting + +### `NO` with `API key not configured` + +Neither `SCADABRIDGE_API_KEY` nor `ScadaBridge:ApiKey` is non-blank. A stale environment variable shadows an edited file; check the `api key source:` log line. + +### `NO` with `HTTP 401` + +The node answered, so the answer is final and no failover happens. Inbound API keys are a per-node store, so a key valid on one central may be absent on the other. + +### `NO` with `OUTCOME_UNKNOWN` + +A timeout or non-refusal transport error. The download may have been applied; verify before retrying manually. + +### `NO` with `config load failed` + +`appsettings.json` is missing or malformed, or a `BaseUrls` entry is not an absolute HTTP(S) URI. + +## Related Documentation + +- [Inbound API](./InboundAPI.md) +- [Site Runtime](./SiteRuntime.md) +- `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md` +- `docs/api-specs/Delmia-Recipe-Download-API.md` diff --git a/docs/components/KpiHistory.md b/docs/components/KpiHistory.md new file mode 100644 index 00000000..d55879b3 --- /dev/null +++ b/docs/components/KpiHistory.md @@ -0,0 +1,114 @@ +# KPI History + +KPI History (#26) is a central-only component that records periodic point-in-time KPI samples into the configuration database, folds them into hourly rollups, and purges both on a retention schedule so the Central UI can draw trend charts. The project owns the recording singleton and its options; the sample sources, repository contract and query service live elsewhere. + +## Overview + +The component lives in `src/ZB.MOM.WW.ScadaBridge.KpiHistory/` and references only Commons (plus Akka and `ZB.MOM.WW.Configuration`). Its pieces are: + +- `KpiHistoryRecorderActor` — a `ReceiveActor` with `IWithTimers`, created as the `kpi-history-recorder` cluster singleton by `AkkaHostedService` on the central role. The Host logs it as "not readiness-gated", so it never affects `/health/ready`. +- `KpiHistoryOptions` and `KpiHistoryOptionsValidator` — the `ScadaBridge:KpiHistory` section and its startup validation. +- `KpiSourceAdmission` — root-provider-owned admission so a replaced actor cannot start a second collection while an earlier one is still running. +- `ServiceCollectionExtensions.AddKpiHistory` — binds and validates the options and registers `KpiSourceAdmission` as a singleton. The actor itself is not registered in DI. + +## Key Concepts + +### Sources, samples, repository + +The recorder is generic over sources: each pass it enumerates every DI-registered `IKpiSampleSource` (`Source` name plus `CollectAsync(capturedAtUtc, ct)` returning `KpiSample` rows) and writes the combined batch via `IKpiHistoryRepository.RecordSamplesAsync`. The sources registered in the codebase are `NotificationOutboxKpiSampleSource`, `SiteCallAuditKpiSampleSource`, `AuditLogKpiSampleSource` and `SiteHealthKpiSampleSource` (each a scoped registration via `TryAddEnumerable`); the matching names are the constants in `KpiSources` (`NotificationOutbox`, `SiteCallAudit`, `AuditLog`, `SiteHealth`). The repository implementation is `KpiHistoryRepository` in the Configuration Database project. + +### Four timers + +`PreStart` arms four timers: + +| Timer | First fire | Cadence | Work | +|---|---|---|---| +| `kpi-sample` | 5 s | `SampleInterval` | Collect from all sources and record one batch stamped with a single `capturedAtUtc`. | +| `kpi-purge` | `PurgeTimerSchedule.InitialDelay(PurgeInterval)` (the interval, capped at 5 minutes) | `PurgeInterval` | `PurgeOlderThanAsync` for raw rows older than `RetentionDays`, then `PurgeRollupsOlderThanAsync` for rollups older than `RollupRetentionDays`. | +| `kpi-rollup` | 10 s | `RollupInterval` | `FoldHourlyRollupsAsync` over `[now truncated to hour − RollupLookbackHours, now truncated to hour)`; the in-progress hour is excluded. | +| `kpi-rollup-backfill` | 15 s, single-shot | once per actor lifetime | Fold the raw-retention window in slices. | + +The fold is an idempotent upsert, so a tick missed during a singleton handover self-heals on the next fold. + +### In-flight guards + +A sample tick is skipped (debug log) while a previous sample pass is still running, and a rollup tick is skipped while either a rollup fold or the backfill is running. Every pass is piped back to the actor with a `...Complete` message on success or failure, so a faulted pass cannot wedge the guard. + +### Source isolation + +Each source is collected in its own DI scope through `KpiSourceAdmission.TryStart`, keyed by registration index. If an earlier collection for that index has not completed, no new one starts and the running task is awaited instead. The wait is bounded by `SourceTimeout` (via `WaitAsync`); the collection also gets a linked token that cancels after `SourceTimeout`. On timeout the source is skipped with a warning while its work stays owned; any other source exception is logged and the pass continues. If no samples were collected nothing is written. A failed batch write is logged and the batch dropped. + +### Backfill + +The backfill first reads `GetLatestRollupHourAsync` (the watermark; failures fall back to a null watermark, meaning the full window). `HandleBackfillPlan` sets the floor to the later of the watermark and `now − RetentionDays`. If that floor is at or after `now − RollupLookbackHours`, rollups are already current and the backfill is skipped. Otherwise the range is cut into 24-hour slices and folded one per tick, with the periodic fold interleaving between slices (a slice tick re-arms after 5 seconds while a periodic fold runs). The backfill latches done when the queue empties. + +### Shutdown + +`PostStop` cancels a shared `CancellationTokenSource`; cancellation-triggered exits are treated as non-failures throughout. + +## Architecture + +### Cross-validation in Host + +`KpiRateFoldIntervalCrossValidator` (Host project) is registered against `IValidateOptions` and fails startup unless `KpiHistory:SampleInterval`, `NotificationOutbox:DeliveredKpiWindow` and `SiteCallAudit:KpiInterval` are all equal. The Notification Outbox and Site Call Audit sample sources rescale their delivered/failed-last-interval counts onto `SampleInterval`, so the hourly sum stays correct regardless; the equality check keeps charted values matching the live Health tiles. + +### Query side + +The Central UI registers `KpiHistoryQueryService` (scoped, in `CentralUI/ServiceCollectionExtensions.cs`), which reads `KpiHistoryOptions` and the repository. `RollupThresholdHours` and `DefaultMaxSeriesPoints` are consumed there; the recorder does not use them. Per the option docs, windows up to `RollupThresholdHours` read raw `KpiSample` rows and wider windows read hourly rollups. + +## Usage + +The component exposes no message API. Wiring is `services.AddKpiHistory(configuration)` in the Host's central composition, and the singleton is started by `AkkaHostedService` with `Props.Create(() => new KpiHistoryRecorderActor(serviceProvider, options, logger))`. To add a metric source, implement `IKpiSampleSource` and register it as a scoped service with `TryAddEnumerable`. + +## Configuration + +`KpiHistoryOptions` is bound from `ScadaBridge:KpiHistory` and validated on start. + +| Key | Default | Validation | +|---|---|---| +| `SampleInterval` | `00:01:00` | > 0 | +| `SourceTimeout` | `00:00:10` | > 0 and <= 1 day | +| `RetentionDays` | `90` | [1, 3650] | +| `PurgeInterval` | `1.00:00:00` | > 0 | +| `DefaultMaxSeriesPoints` | `200` | [2, 5000] | +| `RollupInterval` | `01:00:00` | > 0 | +| `RollupLookbackHours` | `3` | [1, 168] | +| `RollupRetentionDays` | `365` | [1, 3650] and >= `RetentionDays` | +| `RollupThresholdHours` | `168` | >= 24 | + +## Dependencies & Interactions + +- [Commons (#16)](./Commons.md) — `IKpiSampleSource`, `IKpiHistoryRepository`, `KpiSample`, `KpiSources`, `PurgeTimerSchedule`. +- [Configuration Database (#17)](./ConfigurationDatabase.md) — `KpiHistoryRepository`. +- [Notification Outbox (#21)](./NotificationOutbox.md), [Site Call Audit (#22)](./SiteCallAudit.md), [Audit Log (#23)](./AuditLog.md), [Health Monitoring (#11)](./HealthMonitoring.md) — each contributes an `IKpiSampleSource`. +- [Central UI (#9)](./CentralUI.md) — trend charts through `KpiHistoryQueryService`. +- [Host (#15)](./Host.md) — registration, singleton creation and the rate-fold cross-validator. +- [Cluster Infrastructure (#13)](./ClusterInfrastructure.md) — singleton failover. + +## Troubleshooting + +### Host fails to start with "KPI rate-fold interval mismatch" + +`SampleInterval`, `DeliveredKpiWindow` and `KpiInterval` differ; set all three equal. + +### Startup fails on `RollupRetentionDays` + +It must be at least `RetentionDays`, otherwise long-range charts would hit a window where raw rows are purged and no rollup exists. + +### A source never appears in charts + +Look for "exceeded its collection deadline" warnings. A source that ignores cancellation keeps ownership of its slot and is not restarted until the earlier call completes. + +### Long-range charts blank after a deploy + +The backfill runs 15 seconds after singleton start and processes one 24-hour slice per tick; check for the "KPI rollup backfill" log lines. + +## Related Documentation + +- [KPI History design specification](../requirements/Component-KpiHistory.md) +- [Notification Outbox](./NotificationOutbox.md) +- [Site Call Audit](./SiteCallAudit.md) +- [Audit Log](./AuditLog.md) +- [Configuration Database](./ConfigurationDatabase.md) +- [Commons](./Commons.md) +- [Host](./Host.md) diff --git a/docs/components/README.md b/docs/components/README.md index 0478b9ab..c9219092 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -5,9 +5,7 @@ code in `src/` actually works — with real code examples. These complement the [design specs](../requirements/) in `docs/requirements/`: the specs say what a component should do and why; these docs say how the code does it. -Reference docs exist for 24 of the 27 components (plus the shared TreeView, listed below). -ScriptAnalysis (#25), KpiHistory (#26), and DelmiaNotifier (#27) are pending — tracked in -[../plans/2026-07-08-deferred-work-register.md](../plans/2026-07-08-deferred-work-register.md). +Reference docs exist for all 27 components (plus the shared TreeView, listed below). | # | Component | Description | |---|-----------|-------------| @@ -35,7 +33,10 @@ ScriptAnalysis (#25), KpiHistory (#26), and DelmiaNotifier (#27) are pending — | 22 | [Site Call Audit](SiteCallAudit.md) | The central `SiteCallAuditActor`, the `SiteCalls` mirror table, telemetry ingest, and the parked-call Retry/Discard relay. | | 23 | [Audit Log](AuditLog.md) | The append-only audit store: site SQLite hot-path, gRPC telemetry, central ingest and reconciliation, redaction, retention, and KPIs. | | 24 | [Transport](Transport.md) | Encrypted bundle export and import, dependency resolution, conflict detection, and `BundleImportId`-correlated audit. | -| 25 | [Tree View](TreeView.md) | The reusable Blazor tree component — parameters, selection modes, and usage in the Central UI. | +| 25 | [Script Analysis](ScriptAnalysis.md) | The script trust model and validator, the diagnostics-only compile gate, the blocking-wait advisory, and the static flow-shape predictor. | +| 26 | [KPI History](KpiHistory.md) | The central `KpiHistoryRecorderActor` singleton: KPI sampling, hourly rollups and backfill, and retention purge. | +| 27 | [Delmia Notifier](DelmiaNotifier.md) | The `WWNotifier` console client DELMIA shells out to, its YES/NO contract, and pre-execution-only failover. | +| 28 | [Tree View](TreeView.md) | The reusable Blazor tree component — parameters, selection modes, and usage in the Central UI. | ## Related Documentation diff --git a/docs/components/ScriptAnalysis.md b/docs/components/ScriptAnalysis.md new file mode 100644 index 00000000..43cf4ac2 --- /dev/null +++ b/docs/components/ScriptAnalysis.md @@ -0,0 +1,117 @@ +# Script Analysis + +Script Analysis (#25) is the shared, stateless library that decides whether a C# script is acceptable before it is deployed or run: it owns the script trust model (a deny-list of forbidden APIs), the design-time compile gate, an advisory blocking-wait check and the static flow-shape predictor used by the Flow Debugger. It has no actors, configuration section or persistence. + +## Overview + +The library lives in `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/` (net10.0, `Microsoft.CodeAnalysis.CSharp.Scripting` and `.Workspaces`, project reference to Commons only). Before it existed the deny-list, reference set and imports were duplicated across four call sites; `ScriptTrustPolicy` is now the single source of truth and the others delegate to it. + +| Type | Role | +|---|---| +| `ScriptTrustPolicy` | The deny-list, reflection-gateway names, forbidden identifiers, reference sets and default imports. | +| `ScriptTrustValidator` | Two-pass trust analysis: `FindViolations(code, extraReferences?)`. | +| `TrustVerdictCache` | Internal process-wide memo of default-reference verdicts. | +| `RoslynScriptCompiler` | Diagnostics-only compile gate: `ParseDiagnostics`, `Compile`. | +| `CachingScriptMetadataResolver` | Cross-compilation metadata resolver cache. | +| `ScriptCompileSurface`, `TriggerCompileSurface` | Compile-only globals types. | +| `BlockingWaitAnalyzer` | Advisory `.Result` / `.Wait()` detection: `Find(code, globalsType)`. | +| `FlowShapePredictor` | Syntax-only flow prediction: `PredictInboundMethod`, `PredictSiteScript`. | + +## Key Concepts + +### The trust policy + +`ScriptTrustPolicy.ForbiddenScopes` lists prefixes matched against a resolved symbol's containing namespace and type: `System.IO`, `System.Diagnostics.Process` (a single type, so `Stopwatch` stays allowed), `System.Threading`, `System.Reflection`, `System.Net`, `System.Runtime.InteropServices`, `System.Runtime.CompilerServices.Unsafe`, `System.Runtime.CompilerServices.RuntimeHelpers`, `System.Runtime.Loader`, `Microsoft.Win32`, `System.Environment`, `System.GC`, and the concrete ADO.NET providers `Microsoft.Data`, `System.Data.SqlClient`, `System.Data.Odbc`, `System.Data.OleDb`. `System.Data` and `System.Data.Common` are deliberately not forbidden, so scripts reach connections only through the `Database` helper. + +`AllowedExceptions` re-allows `System.Threading.Tasks`, `System.Threading.CancellationToken` and `System.Threading.CancellationTokenSource`. `ReflectionGatewayMembers` (for example `GetType`, `Assembly`, `GetMethod`, `InvokeMember`, `DynamicInvoke`) are rejected regardless of receiver; `Invoke` is deliberately excluded because `delegate.Invoke()` is common. `ForbiddenIdentifiers` is `dynamic` and `Activator`. + +### Two validation passes + +`ScriptTrustValidator.FindViolations` parses the script as `SourceCodeKind.Script` and returns a sorted, de-duplicated list of messages; empty means clean, and blank code returns empty. + +1. **Semantic pass** resolves each identifier, type reference, member access and object creation to its symbol and checks the containing namespace and type against `ForbiddenScopes`. This catches `global::` prefixes, aliases and `using static`. +2. **Syntactic hardening pass** (a `CSharpSyntaxWalker`) rejects reflection-gateway member accesses, forbidden identifiers, and forbidden `using` directives and qualified names, which catches reflection reached through permitted types such as `typeof(string).Assembly.GetType(...)`. + +The class documentation states neither pass is a true sandbox; it is defence in depth. A forbidden type inside `nameof(...)` is flagged on purpose. + +### Reference sets + +Two reference sets are kept apart on purpose: + +- `DefaultReferences` is built from `DefaultAssemblies` (the assemblies of `object`, `Enumerable`, `Math`, the C# runtime binder and `DynamicJsonElement`). It is minimal and mirrors what the site runtime compiles against. It must never include the assemblies hosting forbidden APIs, so that a forbidden type is an undefined symbol and the compile gate independently rejects it. +- `AnalysisReferences` is the full trusted-platform-assemblies set (`TRUSTED_PLATFORM_ASSEMBLIES`) plus `DefaultAssemblies`, used only by the trust validator so every named type resolves to its true namespace. When the TPA list is unavailable (single-file, AOT or trimmed host) it falls back to the minimal set plus `ForbiddenAnchorAssemblies`, sets `AnalysisReferencesDegraded` to `true` and emits a `Trace` warning. `BuildMinimalFallbackReferences()` exposes that fallback for tests. + +Extra references passed to `FindViolations` can only improve resolution; the verdict is by resolved namespace, so they cannot whitelist anything. + +### Verdict cache + +When no extra references are supplied, verdicts are memoised by SHA-256 of the code in `TrustVerdictCache`: two generations of up to 1024 entries each (hot and cold); a cold hit is promoted and overflow discards the cold generation. An analysis that throws is not cached, and stored lists are read-only. + +### Compile gate + +`RoslynScriptCompiler.Compile(code, globalsType, extraReferences, extraImports)` creates a script over `DefaultReferences` and `DefaultImports` (`System`, `System.Collections.Generic`, `System.Linq`, `System.Threading.Tasks`, `ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts`) and returns the error-severity messages from `GetCompilation().GetDiagnostics()`. It does not emit or load, so no assembly is created per script; it also has no catch-all, so an unexpected exception propagates instead of being cached as a "failed to compile" verdict. `ParseDiagnostics` returns syntax errors only. All scripts share one `InteractiveAssemblyLoader` and the `CachingScriptMetadataResolver.Instance`, which memoises `ResolveReference` and `ResolveMissingAssembly` results (including "not found") so repeated compiles do not re-resolve the transitive reference closure. + +### Compile-only surfaces + +`ScriptCompileSurface` is the globals type for compiling scripts: it exposes `Instance`, `Parameters`, `CancellationToken`, `Alarm`, `Scope`, and the members `ExternalSystem`, `Database`, `Notify`, `Scripts`, `Attributes`, `Children`, `Parent`, `Alarms`, `Log`, `Files` with nested stand-in types whose bodies throw `NotSupportedException("compile-only surface")`. It must be kept in step with the real runtime script API by hand. `TriggerCompileSurface` is the narrower surface for trigger expressions (`Attributes`, `Children`, `Parent`), used by the Template Engine validation. + +### Advisory blocking-wait check + +`BlockingWaitAnalyzer.Find` binds the script against the given globals type and reports `.Result`, `.Wait()`, `Task.WaitAll/WaitAny` and `.GetAwaiter().GetResult()` on `Task`/`ValueTask` types. It is advisory, not a trust violation, because making it blocking would break already-deployed scripts on redeploy. Unresolved code is skipped. + +### Flow shape prediction + +`FlowShapePredictor` walks script syntax (text-matching member-access chains with whitespace stripped) and returns a `PredictedFlow` of `PredictedNode` and `PredictedEdge` records in source order. `PredictInboundMethod(methodName, script, resolveRouteScript, maxDepth)` starts with an inbound-request node, expands routed site scripts through the caller's resolver, and ends with a response node. `PredictSiteScript(scriptName, body, maxDepth)` starts with a script-run node. `DefaultMaxDepth` is 4. Non-literal targets are flagged `InputDependent`; branching is not modelled; an early `if (...) return` guard becomes a note on the next node. A parse failure degrades to the request/response pair with the note `parse error`. + +## Architecture + +The library is called synchronously from several components: + +- Site Runtime `ScriptCompilationService` and Inbound API `ForbiddenApiChecker` call `ScriptTrustValidator.FindViolations`. +- Template Engine `ScriptCompiler` runs `RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface))`, the trust validator and `BlockingWaitAnalyzer`; `ValidationService` uses the validator and `RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface))` for trigger expressions. +- Transport `BundleImporter` runs the validator on script bodies at preview and apply. +- Central UI `FlowDebugService` calls the predictor. + +## Usage + +```csharp +var violations = ScriptTrustValidator.FindViolations(code); // trust model +var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)); // compile gate +var advisories = BlockingWaitAnalyzer.Find(code, typeof(ScriptCompileSurface)); +var flow = FlowShapePredictor.PredictSiteScript("Start", code); +``` + +## Configuration + +None. All policy is compiled in `ScriptTrustPolicy`. + +## Dependencies & Interactions + +- [Commons (#16)](./Commons.md) — script-facing types (`ScriptAlarm`, `TrackedOperationId`, `DynamicJsonElement`, flow enums) referenced by the compile surface and predictor. +- [Template Engine (#1)](./TemplateEngine.md) — design-time script and trigger validation. +- [Site Runtime (#3)](./SiteRuntime.md) — execution-time compile and trust gate. +- [Inbound API (#14)](./InboundAPI.md) — method script gate. +- [Transport (#24)](./Transport.md) — import-time gate. +- [Central UI (#9)](./CentralUI.md) — Flow Debugger prediction. + +## Troubleshooting + +### A script passes the trust check but fails to compile + +Expected for forbidden types: `DefaultReferences` omits their assemblies, so the compile gate rejects them as undefined symbols. + +### Compile gate and runtime disagree + +`ScriptCompileSurface` is a hand-maintained mirror of the runtime API; a member present at runtime but absent here compiles at runtime only and fails the design-time gate. + +### `AnalysisReferencesDegraded` is true + +The host has no `TRUSTED_PLATFORM_ASSEMBLIES`; only the documented anchor types are guaranteed to resolve in the semantic pass. + +## Related Documentation + +- [Script Analysis design specification](../requirements/Component-ScriptAnalysis.md) +- [Template Engine](./TemplateEngine.md) +- [Site Runtime](./SiteRuntime.md) +- [Inbound API](./InboundAPI.md) +- [Transport](./Transport.md)