# Delmia Recipe-Download Notifier — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Build `ZB.MOM.WW.ScadaBridge.DelmiaNotifier` — a compact Native-AOT Windows console app that DELMIA shells out to per recipe download, which POSTs to the ScadaBridge Inbound API `DelmiaRecipeDownload` method and reports the legacy `YES`/`NO` + exit-code contract. **Architecture:** Single `Program.cs` orchestrates four testable pieces (arg parser, config loader, notifier failover loop behind an `IRecipeSender` seam, result reporter). Zero third-party NuGet deps; `System.Text.Json` source generator for AOT-safe (de)serialization; raw `HttpClient`. Comma-list of base URLs with **connect-failure-only** failover. **Tech Stack:** .NET 10, C#, Native AOT (`win-x64`), `System.Text.Json` source-gen, xUnit. Central package management (`Directory.Packages.props`); no `Directory.Build.props`. Design doc: [`2026-06-26-delmia-recipe-notifier-design.md`](2026-06-26-delmia-recipe-notifier-design.md). **Cross-cutting conventions (apply to every task):** - TDD: failing test → run (fails) → minimal code → run (passes) → commit. - Run a single test: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests --filter "FullyQualifiedName~"`. - Build the two new projects only (fast): `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. - `TreatWarningsAsErrors=true` everywhere — AOT/trim analyzer warnings are build failures; fix, don't suppress. - Native AOT cannot be published from macOS; all tasks here are managed build/test only (cross-platform). The win-x64 AOT exe is produced on Windows (Task 8). - JSON keys are **PascalCase** (`MachineCode`, `Result`, …) to match the inbound `DelmiaRecipeDownload` contract — keep `System.Text.Json` default naming (no camelCase policy). --- ### Task 1: Scaffold the src + test projects **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (foundation) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj` - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs` - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/appsettings.json` - Create: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj` - Modify: `ZB.MOM.WW.ScadaBridge.slnx` (add both `` lines under the matching `/src/` and `/tests/` folders) **Step 1: Create the src csproj** ```xml Exe net10.0 enable enable true WWNotifier true true ``` **Step 2: Create a stub `Program.cs`** ```csharp namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; internal static class Program { public static int Main(string[] args) => 0; // replaced in Task 7 } ``` **Step 3: Create `appsettings.json`** ```json { "ScadaBridge": { "BaseUrls": "http://localhost:9000", "TimeoutSeconds": 30, "LogPath": "logs/delmia-notifier.log" } } ``` **Step 4: Create the test csproj** ```xml net10.0 enable enable true false ``` **Step 5: Add both projects to `ZB.MOM.WW.ScadaBridge.slnx`** — one `` under ``, one `` under ``. **Step 6: Build both** — `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet build tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: both succeed. **Step 7: Commit** — `git add … && git commit -m "feat(delmia-notifier): scaffold DelmiaNotifier src + test projects"` --- ### Task 2: DTOs + JSON source-generation context **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 3, Task 4 **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownload.cs` - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/RecipeDownloadResult.cs` - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierJsonContext.cs` - Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/JsonContractTests.cs` **Step 1: Failing test** — assert `RecipeDownload` serializes to PascalCase keys and `RecipeDownloadResult` round-trips: ```csharp using System.Text.Json; using ZB.MOM.WW.ScadaBridge.DelmiaNotifier; public class JsonContractTests { [Fact] public void RecipeDownload_serializes_pascalcase() { var json = JsonSerializer.Serialize( new RecipeDownload { MachineCode = "Z28061", DownloadPath = @"C:\r.nc", WorkOrderNumber = "W1", PartNumber = "P1", JobStepNumber = "0100", Username = "op" }, NotifierJsonContext.Default.RecipeDownload); Assert.Contains("\"MachineCode\":\"Z28061\"", json); Assert.Contains("\"DownloadPath\"", json); } [Fact] public void RecipeDownloadResult_deserializes_pascalcase() { var r = JsonSerializer.Deserialize("{\"Result\":true,\"ResultText\":\"ok\"}", NotifierJsonContext.Default.RecipeDownloadResult); Assert.True(r!.Result); Assert.Equal("ok", r.ResultText); } } ``` **Step 2: Run → fails** (types don't exist). **Step 3: Implement** the two DTOs and the context: ```csharp // RecipeDownload.cs namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; internal sealed class RecipeDownload { public string? MachineCode { get; set; } public string? DownloadPath { get; set; } public string? WorkOrderNumber { get; set; } public string? PartNumber { get; set; } public string? JobStepNumber { get; set; } public string? Username { get; set; } } ``` ```csharp // RecipeDownloadResult.cs namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; internal sealed class RecipeDownloadResult { public bool Result { get; set; } public string? ResultText { get; set; } } ``` ```csharp // NotifierJsonContext.cs using System.Text.Json.Serialization; namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; [JsonSerializable(typeof(RecipeDownload))] [JsonSerializable(typeof(RecipeDownloadResult))] [JsonSerializable(typeof(NotifierConfig))] // added in Task 4 internal partial class NotifierJsonContext : JsonSerializerContext; ``` > If Task 4 isn't done yet, temporarily omit the `NotifierConfig` line and add it in Task 4. **Step 4: Run → passes.** **Step 5: Commit** — `feat(delmia-notifier): recipe DTOs + JSON source-gen context` --- ### Task 3: Command-line argument parser **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 2, Task 4 **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ArgParser.cs` - Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ArgParserTests.cs` **Behavior:** parse `-d/--downloadpath -m/--machine -w/--workorder -p/--partnumber` (required) + `-s/--seqop -u/--username` (optional) into a `RecipeDownload`. Return a discriminated result: success(payload) or error(message). Unknown flag or missing required → error with a human reason. **Step 1: Failing tests** ```csharp using ZB.MOM.WW.ScadaBridge.DelmiaNotifier; public class ArgParserTests { [Fact] public void Parses_all_flags() { var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1","-p","P1","-s","0100","-u","op" }); Assert.True(r.Ok); Assert.Equal("Z28061", r.Payload!.MachineCode); Assert.Equal("0100", r.Payload.JobStepNumber); Assert.Equal("op", r.Payload.Username); } [Fact] public void Missing_required_returns_error() { var r = ArgParser.Parse(new[] { "-m","Z28061","-d",@"C:\r.nc","-w","W1" }); // no -p Assert.False(r.Ok); Assert.Contains("partnumber", r.Error, System.StringComparison.OrdinalIgnoreCase); } [Fact] public void Optional_flags_may_be_omitted() { var r = ArgParser.Parse(new[] { "-m","Z","-d","x","-w","W","-p","P" }); Assert.True(r.Ok); Assert.Null(r.Payload!.Username); Assert.Null(r.Payload.JobStepNumber); } [Fact] public void Unknown_flag_returns_error() { var r = ArgParser.Parse(new[] { "-z","x" }); Assert.False(r.Ok); } } ``` **Step 2: Run → fails.** **Step 3: Implement `ArgParser`** — a `ParseResult` record (`bool Ok`, `RecipeDownload? Payload`, `string? Error`) and a `Parse(string[])` that walks pairs, maps short+long flags, validates the four required fields, and returns the first missing/unknown as the error. Keep it allocation-light and reflection-free (AOT-safe). **Step 4: Run → passes.** **Step 5: Commit** — `feat(delmia-notifier): CLI arg parser with required/optional validation` --- ### Task 4: Config loader + API key **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 2, Task 3 **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierConfig.cs` - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ConfigLoader.cs` - Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs` - (Add `[JsonSerializable(typeof(NotifierConfig))]` to `NotifierJsonContext` if not already present.) **Behavior:** `NotifierConfig` POCO (`ScadaBridge` section: `BaseUrls` string, `TimeoutSeconds` int = 30, `LogPath` string?). `ConfigLoader.Load(string jsonText)` deserializes via `NotifierJsonContext`. A helper `SplitBaseUrls(string?)` → trimmed, non-empty `string[]`. `ResolveApiKey(Func envGet)` reads `SCADABRIDGE_API_KEY` (inject the env accessor for testability). **Step 1: Failing tests** — comma split (`"a, b ,,c"` → `[a,b,c]`), default `TimeoutSeconds == 30` when omitted, empty/whitespace key → null, present key → value. **Step 2: Run → fails.** **Step 3: Implement** POCO + loader. `Load` uses `JsonSerializer.Deserialize(jsonText, NotifierJsonContext.Default.NotifierConfig)`. Separate file read (`File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "appsettings.json"))`) into a thin wrapper so the parse logic is unit-tested from a string. **Step 4: Run → passes.** **Step 5: Commit** — `feat(delmia-notifier): config loader + SCADABRIDGE_API_KEY resolution` --- ### Task 5: Notifier failover loop (behind `IRecipeSender` seam) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (core logic; needs Task 2) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/IRecipeSender.cs` (seam: `AttemptKind`, `AttemptOutcome`, `IRecipeSender`) - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Notifier.cs` (the failover loop + `NotifyResult`) - Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/NotifierTests.cs` **Seam:** ```csharp namespace ZB.MOM.WW.ScadaBridge.DelmiaNotifier; internal enum AttemptKind { Connected, ConnectFailed } internal sealed record AttemptOutcome(AttemptKind Kind, int StatusCode, RecipeDownloadResult? Body, string? Error); internal interface IRecipeSender { Task SendAsync(string baseUrl, RecipeDownload payload, CancellationToken ct); } ``` **Loop rule (connect-failure-only failover):** iterate base URLs in order; `ConnectFailed` → record error, continue; `Connected` → authoritative, stop: - 2xx + `Result == true` → success. - 2xx + `Result == false` → failure(`ResultText`). - non-2xx → failure(`HTTP `). - All `ConnectFailed` → failure(`all URLs unreachable: `). **Step 1: Failing tests** using a fake `IRecipeSender` (a queue of scripted outcomes), assert `NotifyResult` (Ok + Reason): - first URL `ConnectFailed`, second `Connected 200 Result=true` → Ok. - first `Connected 200 Result=false` → not Ok, reason from `ResultText`, **second sender never called**. - first `Connected 500` → not Ok, reason contains `500`, second never called. - all `ConnectFailed` → not Ok, reason contains `unreachable`. **Step 2: Run → fails.** **Step 3: Implement** `Notifier.RunAsync(string[] baseUrls, RecipeDownload payload, IRecipeSender sender, CancellationToken ct)` returning `NotifyResult(bool Ok, string Reason)`. **Step 4: Run → passes.** **Step 5: Commit** — `feat(delmia-notifier): connect-failure-only failover loop` --- ### Task 6: Real `HttpClient` sender (outcome classification) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (needs Task 2, Task 5) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/HttpRecipeSender.cs` - Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/HttpRecipeSenderTests.cs` **Behavior:** implements `IRecipeSender` over an injected `HttpClient`. `POST {baseUrl.TrimEnd('/')}/api/DelmiaRecipeDownload`, header `X-API-Key: `, body = `RecipeDownload` JSON via `NotifierJsonContext`. Classification: - `HttpRequestException` (incl. inner `SocketException`) or `TaskCanceledException`/`OperationCanceledException` from timeout → `AttemptOutcome(ConnectFailed, 0, null, ex.Message)`. - Got a response → `Connected`, with `StatusCode`; on 2xx parse body into `RecipeDownloadResult` (tolerate parse failure → `Body=null`, treated as failure by the loop). **Step 1: Failing tests** with a fake `HttpMessageHandler` (`Func`): - handler throws `HttpRequestException` → `ConnectFailed`. - handler returns 200 `{"Result":true,"ResultText":""}` → `Connected`, 200, `Body.Result == true`; assert request had `X-API-Key` header and path `/api/DelmiaRecipeDownload`. - handler returns 500 → `Connected`, 500. **Step 2: Run → fails.** **Step 3: Implement** `HttpRecipeSender`. **Step 4: Run → passes.** **Step 5: Commit** — `feat(delmia-notifier): HttpClient recipe sender with connect-failure classification` --- ### Task 7: `Program.Main` wiring + result reporter + logger **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none (needs Tasks 2–6) **Files:** - Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs` - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Reporter.cs` (maps outcome → stdout + exit code) - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/DiagLog.cs` (stderr + optional file) - Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ReporterTests.cs` **Reporter contract (drop-in parity):** success → write `YES` to a `TextWriter`, return `0`. Failure → write `NO` then the reason line, return `-1`. (Inject the `TextWriter` so tests assert the exact lines.) **Step 1: Failing tests** for `Reporter.Report(bool ok, string reason, TextWriter stdout)`: - ok → stdout is `"YES"` (+ newline), returns `0`. - not ok with reason `"boom"` → stdout is `"NO\nboom"`, returns `-1`. **Step 2: Run → fails.** **Step 3: Implement** `Reporter`, `DiagLog` (writes timestamped lines to `Console.Error` and, if `LogPath` set, appends to the file — create parent dir; never throw), and wire `Program.Main`: 1. `ArgParser.Parse(args)` → on error: `DiagLog`, `Reporter.Report(false, error)`. 2. Load config; if no `BaseUrls` → report false "no BaseUrls configured". 3. `ResolveApiKey`; if null → report false "API key not configured (SCADABRIDGE_API_KEY)". 4. Build `HttpClient { Timeout = TimeSpan.FromSeconds(cfg.TimeoutSeconds) }` + `HttpRecipeSender`. 5. `await Notifier.RunAsync(...)`; log per-attempt diagnostics; `Reporter.Report(result.Ok, result.Reason)`. 6. Wrap in try/catch → on unexpected error: `DiagLog` + report false with the message. **stdout only ever gets `YES`/`NO`+reason.** **Step 4: Run → passes** (reporter tests; full-build the two projects). **Step 5: Commit** — `feat(delmia-notifier): Program wiring, YES/NO reporter, diagnostics log` --- ### Task 8: README, publish/AOT instructions, final verification **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 2–7 docs portion (verification depends on all) **Files:** - Create: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md` - Test: (none new) — run the full suite. **Step 1: Write `README.md`** — purpose (Delmia → ScadaBridge `DelmiaRecipeDownload`), CLI flags table, `appsettings.json` schema, the `SCADABRIDGE_API_KEY` env var, the `YES`/`NO` + exit-code contract, and the **publish** command: ``` # On Windows (Native AOT can't cross-compile from macOS/Linux): dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64 # → WWNotifier.exe (self-contained, single native file) + appsettings.json ``` Plus the manual smoke test (`curl`/run against `wonder-app-vd03` `/api/DelmiaRecipeDownload`). **Step 2: Final verification** — `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier && dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`. Expected: build clean (no warnings, since warnings-as-errors), all tests pass. **Step 3: Commit** — `docs(delmia-notifier): README + publish/AOT instructions` --- ## Notes for the executor - Do **not** add NuGet packages to the src project — BCL only (AOT-clean). The test project uses only the standard xUnit set already in `Directory.Packages.props`. - If any task needs a file not listed in its `Files:` block, that's a plan defect — surface it. - The AOT native publish + live smoke against `wonder-app-vd03` happen on a Windows host; the managed build/test in every task is the cross-platform gate here.