# Design — Delmia Recipe-Download Notifier (`ZB.MOM.WW.ScadaBridge.DelmiaNotifier`) **Date:** 2026-06-26 · **Status:** Approved (brainstorming) · **Next:** implementation plan (writing-plans) ## Purpose A compact Windows console application that **DELMIA Apriso shells out to** on each recipe/NC-program download to notify the plant system that a recipe was placed at a path for a machine. It is the modern replacement for the legacy `WWNotifier` (see [`../former-api-specs/dnc/Delmia-Integration-API.md`](../former-api-specs/dnc/Delmia-Integration-API.md), Surface B), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API** method `DelmiaRecipeDownload`. It is a strict **drop-in** for Delmia's existing call site: same command-line flags, same `YES`/`NO` stdout contract, same exit-code semantics — so Delmia's invocation and output parsing are unchanged. ## Key decisions (from brainstorming) | Decision | Choice | |---|---| | Target | ScadaBridge Inbound API `POST {baseUrl}/api/DelmiaRecipeDownload` | | Auth | `X-API-Key: ` header | | CLI/output | Exact parity with legacy `WWNotifier` (flags + `YES`/`NO` + exit code) | | Config | `appsettings.json` next to the exe (URLs, timeout, optional log path) | | Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file | | Packaging | Self-contained **Native AOT**, `win-x64` (fast startup for per-invocation use) | | Failover | Comma-list of base URLs; advance **only on connect failure** | | Location | New project `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier`, in `ZB.MOM.WW.ScadaBridge.slnx` | | Implementation | Approach A — zero-dependency single-file; hand-rolled arg parser, `System.Text.Json` source-gen, raw `HttpClient` | | Exe name | `AssemblyName = WWNotifier` (literal drop-in for Delmia's existing call path) | ## Architecture Single-purpose console app, no DI/generic host. One `Program.cs` orchestrates four small pieces: 1. **Arg parser** — hand-rolled, the 6 legacy flags → an in-memory request model. 2. **Config loader** — reads `appsettings.json` (via `System.Text.Json` source generator) → POCO; reads the API key from the environment. 3. **Notifier** — builds the JSON payload and runs the failover POST loop behind a small seam (interface/delegate) so the loop + result mapping are unit-testable without real HTTP. 4. **Result reporter** — maps the outcome to the `YES`/`NO` + exit-code contract (stdout) and writes diagnostics (stderr + optional log file). Zero third-party NuGet dependencies (BCL only) to keep the Native-AOT surface minimal and trim-clean. ## CLI contract (drop-in parity) | Short | Long | Required | → payload field | |---|---|---|---| | `-d` | `--downloadpath` | yes | `DownloadPath` | | `-m` | `--machine` | yes | `MachineCode` | | `-w` | `--workorder` | yes | `WorkOrderNumber` | | `-p` | `--partnumber` | yes | `PartNumber` | | `-s` | `--seqop` | no | `JobStepNumber` | | `-u` | `--username` | no | `Username` | - **stdout:** exactly `YES` on success, or `NO` followed by a reason line on failure. Nothing else is written to stdout (Delmia parses it). - **exit code:** `0` on success, `-1` on failure (matches the legacy `Environment.ExitCode`). - A missing required flag → `NO` + reason, exit `-1`, **no** HTTP attempt. ## Configuration & secret `appsettings.json` placed next to the exe, loaded directly into a small POCO with a `JsonSerializerContext` source generator (no reflection-based binder): ```json { "ScadaBridge": { "BaseUrls": "http://host-a:8085,http://host-b:8085", "TimeoutSeconds": 30, "LogPath": "logs/delmia-notifier.log" } } ``` - `BaseUrls` — comma-separated failover list (legacy-style). The method path `/api/DelmiaRecipeDownload` is appended by the app; each entry is a base URL only. - `TimeoutSeconds` — per-attempt `HttpClient.Timeout` (default 30). - `LogPath` — optional diagnostic log file (relative to the exe). Omit to log to stderr only. - **API key** comes from `SCADABRIDGE_API_KEY`. If unset/empty → `NO` + "API key not configured", exit `-1`, no attempt. The key is never read from or written to a file. ## Request / response - Per attempt: `POST {baseUrl}/api/DelmiaRecipeDownload`, header `X-API-Key: `, `Content-Type: application/json`, body = the flat `RecipeDownload` JSON. - DTOs (local, source-gen serializable): - `RecipeDownload { MachineCode, DownloadPath, WorkOrderNumber, PartNumber, JobStepNumber, Username }` - `RecipeDownloadResult { bool Result, string ResultText }` ## Failover (connect-failure only) Try each base URL in order; advance to the next **only when the attempt fails to connect** (no HTTP response came back). A node that responds at all is authoritative — its answer is final. | Attempt outcome | Failover? | Final result | |---|---|---| | **No response** — connection refused/reset, DNS failure, TLS error, or timeout | **Yes** → next URL | only if *all* URLs fail to connect → `NO` + last connection error, exit `-1` | | HTTP 2xx + `Result == true` | No — stop | `YES`, exit `0` | | HTTP 2xx + `Result == false` | No — stop | `NO` + `ResultText`, exit `-1` | | HTTP non-2xx (401/403/4xx/**5xx**) | No — stop | `NO` + status/error, exit `-1` | > Deliberate consequence: a `5xx` from the first node is reported as a failure, **not** rolled over > to the next node — failover is strictly for unreachable nodes. (Revisit only if operations want > `5xx`/`503` to also fail over.) ## Error handling & logging - **stdout is reserved** for the `YES`/`NO` contract. All diagnostics — per-URL attempt, status code, exception detail, which URL answered — go to **stderr** and, if `LogPath` is set, an appended log file. Hand-written; no logging dependency. - Failure reasons surfaced on the `NO` reason line: missing required arg, missing API key, no `BaseUrls` configured, or "all URLs unreachable: \". - The HTTP call sits behind a tiny seam so the failover/result logic is unit-tested without network. ## Project layout & packaging ``` src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj Program.cs # entry, arg parse, orchestration, result reporting Config.cs # POCO + loader RecipeDownload.cs # request DTO RecipeDownloadResult.cs # response DTO NotifierJsonContext.cs # JsonSerializerContext (source gen) appsettings.json # copied to output ``` - `.csproj`: `net10.0`, `OutputType=Exe`, `AssemblyName=WWNotifier`, `PublishAot=true`, `RuntimeIdentifier=win-x64`, `InvariantGlobalization=true`, AOT/trim analyzer warnings treated as errors. Added to `ZB.MOM.WW.ScadaBridge.slnx`. - **Build note:** the AOT `win-x64` native exe must be published **on Windows** (`dotnet publish -c Release -r win-x64`) with the MSVC build tools present — Native AOT does not cross-compile from macOS/Linux. Managed `dotnet build` / `dotnet test` still run cross-platform for development and CI of the logic. ## Testing `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests` (xUnit), all logic-level (no AOT needed, runs cross-platform): - Arg parsing: all-flags, required-missing → failure, optional omitted. - Config: comma-split of `BaseUrls`, defaults (`TimeoutSeconds`), missing file/section. - Payload mapping: flags → `RecipeDownload` JSON (field-for-field). - Result mapping: `Result true/false` and non-2xx → correct stdout + exit code. - Failover loop (via the HTTP seam / a fake handler): connect-failure advances; first responding node is final; `Result==false` does **not** advance; all-unreachable → `NO` + last error. - Missing/empty `SCADABRIDGE_API_KEY` → fail-fast, no attempt. - Manual live smoke against `wonder-app-vd03` (`/api/DelmiaRecipeDownload`) from the Windows build, mirroring the earlier `curl` verification. ## Out of scope (YAGNI) - No retry/backoff beyond the connect-failover loop; no Polly. - No DI/generic host, no `Microsoft.Extensions.*`. - No support for other inbound methods — recipe download only. - No DPAPI/secret-store integration (env var is the agreed mechanism); revisit if required.