# 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~<Name>"`.
- 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 `<Project Path=...>` lines under the matching `/src/` and `/tests/` folders)

**Step 1: Create the src csproj**

```xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AssemblyName>WWNotifier</AssemblyName>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
    <!-- No <RuntimeIdentifier> here: pass -r win-x64 at publish (Task 8) so build/test stay cross-platform. -->
  </PropertyGroup>
  <ItemGroup>
    <InternalsVisibleTo Include="ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests" />
  </ItemGroup>
  <ItemGroup>
    <None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>
</Project>
```

**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
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="coverlet.collector" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <PackageReference Include="xunit" />
    <PackageReference Include="xunit.runner.visualstudio" />
  </ItemGroup>
  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />
  </ItemGroup>
</Project>
```

**Step 5: Add both projects to `ZB.MOM.WW.ScadaBridge.slnx`** — one `<Project Path="src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.csproj" />` under `<Folder Name="/src/">`, one `<Project Path="tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests.csproj" />` under `<Folder Name="/tests/">`.

**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<string,string?> 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<AttemptOutcome> 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 <status>`).
- All `ConnectFailed` → failure(`all URLs unreachable: <lastError>`).

**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: <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<HttpRequestMessage, HttpResponseMessage>`):
- 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.
