# 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: <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: <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.)

## Delivery semantics: at-least-once

The connect-failure-only failover loop above is by design (no code change here) — but its
consequence is at-least-once delivery of the `DelmiaRecipeDownload` POST, not exactly-once. Two
cases both count as "no response" from the client's point of view, trigger failover to the next
URL, and produce a duplicate POST even though the first node may have already processed the
request:

1. **Connection reset after the server processed the POST** — the request reaches the site and is
   applied, but the response never makes it back to the client (dropped connection, reset). The
   client sees a connect failure (row 1 of the table above), rolls over, and re-POSTs to the next
   `BaseUrls` entry.
2. **Slow response exceeding `TimeoutSeconds`** — the server is still processing (or has already
   finished) when the client's `HttpClient.Timeout` fires. The client treats the timeout the same
   as an unreachable node, rolls over, and re-POSTs.

**Requirement:** the deployed `DelmiaRecipeDownload` inbound method **MUST be idempotent** on the
natural key `(MachineCode, DownloadPath, WorkOrderNumber)`. A duplicate POST for the same key must
return the same `Result`/`ResultText` as the original call, not double-apply side effects (e.g. not
queue the recipe download twice). This is a contract on the inbound method's script, not on the
DelmiaNotifier client — the client cannot distinguish a "duplicate delivered" retry from a
"first delivery" attempt, so idempotency must live on the receiving side. See the timeout row in
the failover table above for the triggering condition.

## 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: \<last error\>".
- 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.
