docs: Delmia rig stand-in + notifier verification results (A-G incl. active-central kill/takeover 27 s, rejoin); register rows 107 (prod DelmiaRecv path) + 108 (per-node inbound API key store breaks central failover); CLAUDE.md bullet; plan + task file

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
Joseph Doherty
2026-09-02 12:54:58 -04:00
parent f4363e0b4b
commit 081e57f166
6 changed files with 744 additions and 0 deletions
+1
View File
@@ -119,6 +119,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- SMS delivery adapter (T9/T10, 2026-06-19): `SmsNotificationDeliveryAdapter` — Twilio REST, no SDK, HTTP Basic auth (`AccountSid:AuthToken`), one `POST .../Messages.json` per recipient. `SmsConfiguration` entity (`AccountSid`, `AuthToken` encrypted via Data Protection, `FromNumber`/`MessagingServiceSid`, `ApiBaseUrl`, timeout/retry) stored centrally; managed Admin-only via CLI `notification sms list|update` + Central UI `/notifications/sms`. Central-only (never deployed to sites). Per-recipient rollup: all-accepted → Delivered; any-transient → retry/park; mix → delivered-to-good + note; all-permanent → Park. `SmsConfiguration` (Auth Token in SecretsBlock) travels in Transport bundles via `--sms-configs`.
- Inbound API: `POST /api/{methodName}`, `X-API-Key` header, flat JSON, extended type system (Object, List).
- **Inbound API on a STANDBY central node RELAYS to the active peer (2026-08-28)** instead of answering 503 `STANDBY_NODE`. Method scripts/`Route.To()` still run only on the active node (`IActiveNodeGate`); the standby's `InboundApiEndpointFilter` hands the request to `IStandbyRequestForwarder` (`HttpStandbyRequestForwarder`, plain HTTP, verbatim method/path/query/body/headers, peer response replayed unchanged) when `ScadaBridge:InboundApi:StandbyForwardPeerEndpoints` lists the *other* node's HTTP base URL — a static appsettings list, because the Akka member address carries the remoting port, not the HTTP one. Empty list = old 503 behaviour. Peers are tried in order; a peer 503 stamped `X-ScadaBridge-Standby: 1`, a connection failure or a `StandbyForwardTimeout` (60 s, must exceed the longest method timeout) expiry each move to the next, all failing → 503 `ACTIVE_NODE_UNREACHABLE`. **One-hop loop guard:** relayed requests carry `X-ScadaBridge-Forwarded` and are never relayed again. The 413 body cap runs *before* the relay, and the standby's `AuditWriteMiddleware` skips its own row for a relayed call (`InboundApiEndpointFilter.RelayedToActiveItemKey`) so it is audited once, on the node that executed it. Both rigs (`docker/`, `docker-env2/`) configure it on both central nodes; QA/prod appsettings are user-held and need the key added by hand. Spec: `Component-InboundAPI.md` → *Standby forwarding*.
- **DelmiaNotifier rig stand-in + the per-node inbound-key-store trap (2026-09-02).** `docker/seed-delmia-sim.sh` (in-memory `SimMachine` composing `DelmiaReceiverSim` as `DelmiaReceiver`, a `ValueChange` script on the ROOT template playing the Galaxy side) + `seed-delmia-api.sh` (prod's `DelmiaRecipeDownload` method + a scoped key) + `verify-delmia-notifier.sh` (notifier A–G incl. an active-central `docker kill`, takeover measured 27 s, rejoin as standby). The site script is a **static-attribute port** of the prod handshake — `WriteBatchAndWaitAsync` rejects static attributes, so it uses per-attribute `SetAsync` + `WaitAsync`. Two facts the drill established: (1) `docker stop` is a graceful CoordinatedShutdown handover (takeover 0 s) — crash drills must `docker kill`; (2) **inbound API keys live in a PER-NODE SQLite file** (`ScadaBridge:InboundApi:ApiKeyStore:SqlitePath`, each central's own volume), written by whichever node serves the management call, so a key exists on one node only and **a central failover makes every inbound client 401** — the standby-relay path only works because the standby forwards before authenticating. `docker/sync-inbound-api-keys.sh` is the rig-only mirror; the real fix is register row 108. Results: `docs/api-specs/Delmia-Recipe-Download-API.md` §8.
### Templates & Deployment
- Pre-deployment validation includes semantic checks (call targets, argument types, trigger operand types).
@@ -197,3 +197,68 @@ revision, and one `MachineCode must end with A (Left) or B (Right)` from a retir
3. `required` is empty in the parameter schema; only `MachineCode` is enforced, in script. Missing
payload fields are written to the receiver as null/empty.
4. QA definitions unread (credentials) — assumed identical (same build lineage), not verified.
## 8. Docker rig stand-in and notifier verification (2026-09-02)
The local rig (`docker/`, central-a `:9001` / central-b `:9002` / Traefik `:9000`, site-a = site id 1)
carries an in-memory stand-in for a vd03-style machine so the whole path can be exercised without a
Galaxy. Seeded by CLI only, no DB edits:
- `docker/seed-delmia-sim.sh` — template **`DelmiaReceiverSim`** (the ten §5 attributes, all **static**,
`ReadyFlag` defaulting to `true`) composed into **`SimMachine`** as `DelmiaReceiver`; instance
**`DelmiaSim01`** on site-a, deployed. `SimMachine` carries two scripts:
- `ProcessRecipeDownload` (`Call`) — the §4 handshake ported to static attributes. `WriteBatchAndWaitAsync`
**rejects static attributes** (`InstanceActor.HandleWriteAttributeBatch`: "not a data-sourced
attribute"), so the port writes the payload with per-attribute `SetAsync` and waits with `WaitAsync`
— same trigger/response flags, same 25 s budget, same result mapping. It addresses
`Children["DelmiaReceiver"]` (the post-rename name; contrast §7 item 1).
- `DelmiaReceiverSimulator` (`ValueChange` on `DelmiaReceiver.RecipeDownloadFlag`) — plays the Galaxy
object: on the rising edge it accepts unless `DownloadPath` ends in `.bad` or the work order is blank,
writes `RecipeProcessResult`/`RecipeProcessResultText`, clears the trigger flag, then sets
`RecipeProcessedFlag = true`. It lives on the **root** template because composed scripts' trigger
configs are not prefixed at flattening, and `ScriptActor` matches `ValueChange` by exact canonical name.
- `docker/seed-delmia-api.sh` — the §4 method verbatim (minus its dead `suf` line), timeout 45 s, plus one key
(`delmia-notifier`) scoped to it; the token is printed once.
- `docker/sync-inbound-api-keys.sh` — **required before any central failover test**, see below.
- `docker/verify-delmia-notifier.sh` — runs the managed `WWNotifier.dll` from a scratch copy whose
`appsettings.json` carries the key (`ScadaBridge:ApiKey`), with `BaseUrls` listing the two central nodes
directly (not Traefik) so the notifier's own connect-failure failover is what gets exercised.
Whole-path latency through Traefik: a `curl` call returns in under 100 ms (simulator fires on the same
node, no device round-trip).
### Verified outcomes (second run, 2026-09-02 16:51 UTC)
| Case | Setup | Result | Audit row (`ApiInbound`) |
|------|-------|--------|--------------------------|
| A | active (central-a) first in `BaseUrls` | `YES` | `InboundRequest` on `central-a` |
| B | standby (central-b) first — standby relays | `YES` | **one** row, on `central-a` (the relay does not double-audit) |
| C | bad key | `NO` + `HTTP 401` | `InboundAuthFailure` on `central-a` |
| D | active `docker kill`ed, attempt inside the takeover window | `NO` + `HTTP 503` (`ACTIVE_NODE_UNREACHABLE` from the not-yet-active survivor) — expected, connect-failure-only failover | none (infrastructure answer) |
| E | survivor `/health/active` = 200 — takeover measured **27 s** after the kill | `YES` (`:9001` refused → `:9002` 200) | `InboundRequest` on `central-b` |
| F | victim restarted, `memberCount=2`, victim `/health/active` = 503 (rejoined as standby) | `YES` (victim relays) | `InboundRequest` on `central-b` |
| G | `DownloadPath` = `C:\recipes\x.bad` | `NO` + `Simulated reject: bad path or missing work order` | `InboundRequest` on `central-b`, `Result=false` |
The notifier's first-line contract, exit codes (`0` / `-1`), and the reason line on `NO` behaved as the
README states throughout.
### Two findings from the first run (kept, not "fixed")
1. **`docker stop` is not a crash.** SIGTERM triggers CoordinatedShutdown's graceful singleton handover —
the survivor was active **0 s** after the stop. The drill uses `docker kill` so auto-down's ~25–30 s
takeover is what gets measured (`docker/failover-drill.sh` already does the same).
2. **Inbound API keys are a per-node SQLite store, so a central failover invalidates every client's key.**
Keys live in `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` (`/app/data/inbound-api-keys.sqlite`, on
each central's own data volume), and key admin (`ManagementActor`, a per-node `ActorOf` reached from that
node's `/management` endpoints) writes only the node that serves the call — through Traefik that is the
active node. After the first run's takeover, central-b answered **401 `KeyNotFound`** to the key
central-a had minted; its `security api-key list` was `[]`. The standby-relay case (B) passes only
because the standby forwards **before** authenticating. The same asymmetry means a key revoked on one
node stays valid on the other. Rig workaround: `docker/sync-inbound-api-keys.sh` checkpoints central-a's
store on the host (`sqlite3`) and replaces central-b's (target stopped for the copy; both rig nodes share
one pepper, so the hashes verify on either node); the verify script's preflight refuses to start unless
both nodes list the key. The product fix is an architecture decision (shared SQL Server store or file
replication, with cluster-wide revocation) and needs the `ZB.MOM.WW.Auth.ApiKeys` library — deferred-work
register row 108. Prod `wonder-app-vd03` is single-node and unaffected; the QA pair has this gap today.
Deferred-work register row 107 records §7 item 1 (`Children["DelmiaRecv"]`) as the prod follow-up.
@@ -99,6 +99,8 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 104 | ~~**Existing site LocalDb files stay `auto_vacuum=NONE`; `PRAGMA incremental_vacuum` after a cap trim is a no-op on them**~~ **RESOLVED 2026-08-20 — recipe shipped and applied to the rig.** New `docker/vacuum-site-localdb.sh` (stop both nodes of a pair together → `PRAGMA auto_vacuum=INCREMENTAL; VACUUM` each volume with integrity checks before/after → start both; `DRY_RUN=1` reports) plus the topology-guide section *Site LocalDb files created before 2026-08-20 need a one-off offline VACUUM*. Run on all three rig pairs: all six files `auto_vacuum 0 → 2`, `integrity_check=ok`, ~11 % smaller (site-a 20.3 → 18.1 MB), site-a replication back `Connected=true`/backlog 0 with no watermark or schema change — the VACUUM was replication-invisible as predicted (ledger keys by declared PK; oplog `seq` is an `INTEGER PRIMARY KEY AUTOINCREMENT`). The same maintenance surfaced the unrelated row 106. Original: | Arch-review 2026-08-19 P4-6 (Low-wave T4, `47f03fe6`) | `auto_vacuum` is only honoured on a table-less database, and `LocalDbSchema.EnsureCreated` had always created LocalDb's bookkeeping tables before `SiteEventLogSchema.Apply` ran — so the pragma had never once taken effect on any real file. The fix (`SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile`, run before `AddZbLocalDb`'s lazy singleton constructs `SqliteLocalDb`) only helps **fresh** files; an unconditional `VACUUM` against a live ~1 GB replicated file was explicitly rejected (long exclusive lock on the active node; the rewrite is CDC-invisible but blocks the sync pump). Every node on the docker rig today predates the fix, so freed `site_events` pages there are reused, not returned to the OS — the storage cap (row 81) still bounds the data volume, only the file size stops shrinking. | Operator-scheduled `VACUUM` in a maintenance window (both nodes of a pair stopped together), or when a site's file is next recreated. |
| 105 | ~~**Design constraint (not a defect): a LocalDb-replicated table must never be the parent of a row-mutating `ON DELETE` foreign key (`CASCADE`/`SET NULL`/`SET DEFAULT`) from another table**~~ **PINNED 2026-08-20.** Enforcement moved from the first replicated boot to the build: `SiteLocalDbReplicatedTableFkGuardTests` (Host.Tests) builds the real site schema via `OnReady` on an unreplicated config (so 0.3.3's own guard can't mask which FK is at fault), walks `SiteLocalDbSetup.ReplicatedTables` (now `internal`), asserts each exists and has no inbound row-mutating `ON DELETE` FK with the same `sqlite_master JOIN pragma_foreign_key_list` query LocalDb uses, and carries a negative control proving the checker finds a cascading child. The constraint itself stands — the row is kept as its record. Original: | Arch-review 2026-08-19 P4-7 → LocalDb 0.3.3 (scadaproj `122940c` + review follow-up `9663279`, Low-wave T5) | `LwwApplier` upserts with `INSERT OR REPLACE` under `foreign_keys=ON`, so the implicit delete fires the child action while CDC capture is suppressed — the child rows change on the applying node only and the pair diverges silently. 0.3.3 makes `RegisterReplicated` refuse such a parent at registration (clear exception naming the child FK), which is a BOOT failure for the site node, so this is enforced, not advisory. None of the ten `SiteLocalDbSetup.ReplicatedTables` has any FK today (zero `REFERENCES` on the live rig file). Model cross-table cleanup as explicit application deletes on each table (as the 3-table deploy cascade already does), never as SQLite FK actions. | Whenever a schema change adds a foreign key between site tables, or an eleventh table joins `ReplicatedTables`. |
| 106 | ~~**LocalDb: a converged pair re-streams a FULL snapshot on EVERY reconnect once the sender's oplog has been ack-pruned to empty and the receiver's watermark is 0**~~ **RESOLVED 2026-08-20 — LocalDb 0.3.4 (scadaproj `75c1897`), published and pinned.** `SnapshotStreamer.SendAsync` now stamps `as_of_seq = max(MAX(seq), last_acked_seq)`; pinned by `WipedPeer_SnapshotLiftsItsWatermark_SoTheNextReconnectSendsNoSecondSnapshot` (fails on 0.3.3 exactly as the rig did: watermark 0, expected 3). Live proof on the rig: the first restart on 0.3.4 sent one last full snapshot stamped `as_of_seq 149` (was 0) and node-a's `last_applied_remote_seq` went 0 → 149; a second restart of the pair exchanged **zero** snapshots, `Connected=true`, backlog 0. Original: | Found 2026-08-20 while verifying row 104 on the rig (site-a-a received the same 127 k-row full snapshot at both the 13:09 deploy restart and the 13:25 VACUUM restart) | Sender-side defect in `SnapshotStreamer.SendAsync`: `as_of_seq = MAX(seq)` of the oplog, which is **0** once the pair has converged and ack-pruning emptied it. The receiver's `SetLastAppliedRemoteSeqAsync` is MAX-guarded (no regression — good), but a peer whose watermark is genuinely 0 (site-a-a was rebuilt after the 2026-08-15 corruption; any wiped/re-imaged node is the same) can never be lifted by an `as_of_seq 0` snapshot, so the 0.1.2 empty-oplog horizon rule (`streamableFrom = last_acked_seq + 1`) sees `0 < 149` as a gap on every handshake and re-streams everything. Rig state: node-b `last_acked_seq=149`, oplog empty; node-a `last_applied_remote_seq=0`. Not a divergence (LWW absorbs the re-delivered rows) but a cost/availability bug — ~1 s and 127 k rows here, minutes and a full-file read on a 1 GB production pair, on every reconnect, until the sender happens to write a new row. Fix is sender-side and wire-compatible: stamp `as_of_seq = max(MAX(seq), last_acked_seq)` (a full snapshot taken now covers every delta ever pruned below `last_acked_seq`), so the receiver's MAX-guarded watermark lifts to 149 and the loop ends; add a pinning test (converged+pruned sender, zero-watermark receiver → exactly one snapshot across two handshakes). Ships as LocalDb 0.3.4. | Next LocalDb release (0.3.4) — small, well-understood, and worth doing before any production pair is rebuilt from its peer. |
| 107 | **Prod `CvdReactor.ProcessRecipeDownload` still addresses `Children["DelmiaRecv"]` after the 2026-06-27 `DelmiaRecv` → `DelmiaReceiver` composition rename** — inferred from the deployed script (`docs/api-specs/Delmia-Recipe-Download-API.md` §7 item 1): `ChildrenAccessor` concatenates the name into a path, an unknown path yields null, so every call answers `Receiver ready flag not set to true` regardless of the real flag. No call has hit prod since the rename, so this is inferred, not observed. The docker rig's stand-in (`docker/seed-delmia-sim.sh`) uses `Children["DelmiaReceiver"]` and the same handshake answers correctly, which is the working reference. | Found 2026-09-02 while capturing the as-deployed spec; QA copy unread (credentials) | Separate change with its own deployment record: `scadabridge template script update` on the `CvdReactor` template + redeploy Z28061/Z28062/Z28061Sim, then a live `WWNotifier` call. | Before the first production Delmia recipe download. |
| 108 | **Inbound API keys are a PER-NODE SQLite store — a central failover invalidates every inbound client's key** *(High, availability + security-adjacent)*. `ZB.MOM.WW.Auth.ApiKeys` keeps keys in `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` (`/app/data/inbound-api-keys.sqlite`, on each central node's OWN data volume), and key admin (`ManagementActor`, a plain per-node `ActorOf` reached in-process from that node's `/management` endpoints) writes only the node that serves the call — via Traefik that is always the active node. So a key exists on exactly one node: after an active-central crash the survivor answers **401 `KeyNotFound`** to every inbound call (found live 2026-09-02 by `docker/verify-delmia-notifier.sh`: the standby-relay case passes only because the standby forwards BEFORE authenticating). The same asymmetry means a key revoked on one node stays valid on the other. Rig workaround: `docker/sync-inbound-api-keys.sh` mirrors the file (both nodes share one pepper); the verify script's preflight refuses to run unless both nodes list the key. Prod `wonder-app-vd03` is single-node; the QA pair (`vq03`/`vq04`) has this gap today. | `docs/api-specs/Delmia-Recipe-Download-API.md` §8; `docker/sync-inbound-api-keys.sh` header | Fix is an architecture decision, not a patch: move the store to the shared SQL Server (as Secrets 0.5.0 did for its central store) or replicate the file; either way revocation must become cluster-wide. Needs the `ZB.MOM.WW.Auth.ApiKeys` library (scadaproj), not just this repo. | Before any 2-node central goes live with inbound API clients; before the next arch-review wave. |
| 68 | ~~**`deploy.sh` run from a git worktree silently rehomes every node's bind-mount data dir**~~ **RESOLVED 2026-08-15 (guard added in the same close-out).** The compose files' bind mounts are relative (`./site-a-node-a/data`), so a deploy from a worktree checkout mounts the worktree's copies — the rig keeps running and looks healthy, but its SQLite stores split from the main checkout's and everything written there is orphaned by the next deploy from main. Bit the rows-46–53 close-out live: the seeding agent's `rig-probe-1` deploy persisted into worktree-homed dirs and "vanished" after the orchestrator's redeploy (no tombstone, no product bug — LWW/tombstone forensics ruled out deletion; the row sat in `.claude/worktrees/…/docker/site-a-node-a/data/site-localdb.db` the whole time). Both `docker/deploy.sh` and `docker-env2/deploy.sh` now refuse to run from a `.claude/worktrees/` path (override: `SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1`). | Found during the rows-46–53 close-out | Closed 2026-08-15. | — |
## Resolved (verified against the code 2026-07-10)
@@ -0,0 +1,582 @@
# Delmia Simulated Machine + Notifier Rig Verification — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Stand up an in-memory (static-attribute) stand-in for a vd03-style machine with a composed `DelmiaReceiver` on the local docker rig, wire the `DelmiaRecipeDownload` inbound method and a scoped API key to it, let `DelmiaNotifier` read that key from `appsettings.json`, and prove the notifier end-to-end on the 2-node central cluster including an active-node crash and rejoin.
**Architecture:** Two templates seeded by CLI only (no DB edits): `DelmiaReceiverSim` (the ten receiver attributes, all static — no data connection) composed into `SimMachine` as `DelmiaReceiver`. The Galaxy side of the handshake is replaced by a root-scope `ValueChange` script on `SimMachine` that reacts to `DelmiaReceiver.RecipeDownloadFlag`, so the same call script shape as prod runs against pure in-memory tags. The central inbound method is the prod script verbatim (minus its dead `suf` line). The notifier gains an optional `ScadaBridge:ApiKey` config field with the existing `SCADABRIDGE_API_KEY` env var taking precedence, and a bash verification script drives the whole thing against `docker/` (central-a `:9001`, central-b `:9002`, Traefik `:9000`).
**Tech Stack:** ScadaBridge CLI (`scadabridge`), bash, .NET 10 (`DelmiaNotifier` + xUnit tests), docker compose rig under `docker/`.
**Facts the implementer must not re-derive** (verified 2026-09-02):
- `WriteBatchAndWaitAsync` **rejects static attributes** (`InstanceActor.HandleWriteAttributeBatch`: "Attribute 'X' is not a data-sourced attribute"). The rig script therefore uses `SetAsync` per attribute + `WaitAsync`, which both work on static attributes. Do not "fix" this in the runtime — out of scope.
- A static write always publishes `AttributeValueChanged` (no equality short-circuit), and `ScriptActor` matches a `ValueChange` trigger by **exact** canonical name (`valueTrigger.AttributeName == changed.AttributeName`). Composed scripts' trigger configs are **not** prefixed at flattening (alarms are), so the simulator script lives on the **root** template with `attributeName` = `DelmiaReceiver.RecipeDownloadFlag`.
- `WaitAsync` has a fast path when the current value already matches, so the simulator finishing before the wait registers is not a race.
- Trigger types are matched case-insensitively as strings: `Call`, `ValueChange`, `Interval`, `Conditional`, `Expression`. Trigger config key is `attributeName`.
- CLI global options (`--url`, `--username`, `--password`, `--format=json`) must come **after** the subcommand. Rig creds: `--username multi-role --password password`. Write loops in **bash** (zsh does not word-split unquoted variables).
- `security api-key create --format=json` returns `{ "keyId": …, "token": "sbk_…" }`; the token is shown once.
- Rig: site-a is `--site-id 1`; Traefik `http://localhost:9000` → active central; direct nodes `:9001` (central-a) and `:9002` (central-b); both centrals have `StandbyForwardPeerEndpoints` set, so a standby **relays** inbound calls to the active peer. Auto-down failover of a crashed active takes ~25–30 s (`docker/failover-drill.sh`).
- The notifier's failover is **connect-failure-only**: any HTTP answer (incl. 503) is final. During the takeover window a standby that cannot reach its peer answers 503 `ACTIVE_NODE_UNREACHABLE` → the notifier prints `NO`. That is expected and must be documented, not "fixed".
- Prod's deployed method + site script are recorded in `docs/api-specs/Delmia-Recipe-Download-API.md`; prod's site script still references `Children["DelmiaRecv"]` (a known, separate defect). The rig script uses `Children["DelmiaReceiver"]`.
**Model assignment summary** (see each task's `Model:` line): Sonnet for scripted CLI seeding, config plumbing, and docs; Opus for the live rig verification and the failover drill (judgment under a live system) and for reviewing the secret-handling change; Fable (this session) for the final integration review.
---
### Task 0: Preflight — rig healthy, CLI built, notifier tests green
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (everything depends on it)
**Model:** sonnet
**Files:**
- (none edited)
**Step 1: Confirm the rig is up and which node is active**
Run:
```bash
for p in 9000 9001 9002; do printf "$p ready="; curl -s -m 5 -o /dev/null -w "%{http_code}" http://localhost:$p/health/ready; printf " active="; curl -s -m 5 -o /dev/null -w "%{http_code}\n" http://localhost:$p/health/active; done
```
Expected: all three `ready=200`; exactly one of `9001`/`9002` shows `active=200`, the other `503`; `9000 active=200`. If a node is down, run `bash docker/deploy.sh` first and re-check.
**Step 2: Build the CLI and the notifier, run the notifier tests**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge
dotnet build src/ZB.MOM.WW.ScadaBridge.CLI -c Debug 2>&1 | tail -3
dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests 2>&1 | tail -5
```
Expected: `Build succeeded` and `Passed!` with 0 failed. Note the CLI binary path used by every later task: `src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge`.
**Step 3: Confirm the names this plan creates are free on the rig**
Run:
```bash
CLI=src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge; A="--url http://localhost:9000 --format=json --username multi-role --password password"
bash -c "$CLI template list $A | jq -r '.[].name' | grep -E '^(DelmiaReceiverSim|SimMachine)$'; $CLI api-method list $A | jq -r '.[].name' | grep -E '^DelmiaRecipeDownload$'; $CLI instance list $A | jq -r '.[].uniqueName' | grep -E '^DelmiaSim01$'; echo done"
```
Expected: only `done` printed. If any name is taken, delete it (`instance delete --id`, `template delete --id`, `api-method delete --id`) before continuing — the seed script is not idempotent by design.
---
### Task 1: `DelmiaNotifier` — optional `ApiKey` in `appsettings.json`, env var wins
**Classification:** small (single-project logic change, but it relaxes a documented secret-handling decision — reviewer must weigh in)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3
**Model:** sonnet (implement) → **opus** code review, with the explicit question "is the env-over-file precedence and the README wording adequate for a key that now may live on disk?"
**Design note (state this in the commit):** the 2026-06-26 design deliberately kept the key env-only. The user has asked for the key in `appsettings.json`. Keep env-only as the *recommended* production posture, add the file as a fallback, and keep the precedence deterministic: `SCADABRIDGE_API_KEY` (if non-blank) → `ScadaBridge:ApiKey` (if non-blank) → not configured. The failure message names both sources.
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierConfig.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ConfigLoader.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs:42-47`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/appsettings.json`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md` (the `## Secret — SCADABRIDGE_API_KEY` section and the config JSON block)
- Modify: `env_variables.md` (§5 `SCADABRIDGE_API_KEY` row — "env-only by design" is no longer true)
- Modify: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md` (the `Secret` row of the key-decisions table: add "superseded 2026-09-02: file fallback added, env still wins")
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs`
**Step 1: Write the failing tests** (append to `ConfigLoaderTests.cs`)
```csharp
[Fact]
public void Load_reads_ApiKey_from_ScadaBridge_section()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\",\"ApiKey\":\"sbk_file\"}}");
Assert.Equal("sbk_file", cfg.ScadaBridge.ApiKey);
}
[Fact]
public void ResolveApiKey_prefers_env_over_config()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\"sbk_file\"}}");
Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_env" : null, cfg));
}
[Fact]
public void ResolveApiKey_falls_back_to_config_when_env_unset_or_blank()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" sbk_file \"}}");
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => null, cfg));
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => " ", cfg));
}
[Fact]
public void ResolveApiKey_returns_null_when_neither_source_set()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" \"}}");
Assert.Null(ConfigLoader.ResolveApiKey(_ => null, cfg));
}
```
Also update the two existing `ResolveApiKey_*` tests to pass a config: `ConfigLoader.ResolveApiKey(_ => null, new NotifierConfig())` etc. (the one-argument overload is removed, not kept — YAGNI).
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests 2>&1 | tail -5`
Expected: build error `'ScadaBridgeSection' does not contain a definition for 'ApiKey'` / no overload for `ResolveApiKey` taking 2 arguments.
**Step 3: Implement**
`NotifierConfig.cs` — add to `ScadaBridgeSection`:
```csharp
/// <summary>Optional inbound API key. <c>SCADABRIDGE_API_KEY</c> in the environment takes precedence; this is the file fallback (design 2026-06-26, relaxed 2026-09-02).</summary>
public string? ApiKey { get; set; }
```
`ConfigLoader.cs` — replace `ResolveApiKey`:
```csharp
/// <summary>Resolve the API key: <c>SCADABRIDGE_API_KEY</c> wins when non-blank, otherwise <c>ScadaBridge:ApiKey</c> from <paramref name="cfg"/> (trimmed); null when neither is set. Env accessor is injected for testability.</summary>
/// <param name="envGet">Accessor used to read an environment variable by name.</param>
/// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param>
/// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns>
public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg)
{
var env = envGet(ApiKeyEnvVar);
if (!string.IsNullOrWhiteSpace(env)) return env;
var file = cfg.ScadaBridge.ApiKey;
return string.IsNullOrWhiteSpace(file) ? null : file.Trim();
}
```
`Program.cs` lines 42–47 become:
```csharp
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable, cfg);
if (apiKey is null)
{
log.Write("API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)");
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)", stdout);
}
```
`appsettings.json` — add `"ApiKey": ""` after `LogPath` (empty = not set; **never commit a real token**).
README: in the config JSON block add `"ApiKey": ""`; rewrite the `## Secret` section:
> The inbound API key is sent as `X-API-Key`. It is resolved from **`SCADABRIDGE_API_KEY`** first (recommended for production — the key never touches disk), falling back to **`ScadaBridge:ApiKey`** in `appsettings.json` when the variable is unset or blank. If neither is set → `NO` + `API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt. If you use the file, restrict its ACL to the account DELMIA runs under.
`env_variables.md` §5: change the "In appsettings?" cell to `Yes — optional \`ScadaBridge:ApiKey\` fallback (2026-09-02); env wins` and the Purpose cell's "Read only from the environment, never a file" to "Environment first, file fallback".
Design doc table row `| Secret | API key from env var … — never in a file |` → append ` **(relaxed 2026-09-02: optional \`ScadaBridge:ApiKey\` file fallback; env still wins)**`.
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests 2>&1 | tail -5`
Expected: `Passed!`, 0 failed, total = previous count + 4. Also `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -warnaserror` succeeds (the project treats warnings as errors; the XML doc comments above are required).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests env_variables.md docs/plans/2026-06-26-delmia-recipe-notifier-design.md
git commit -m "feat(delmia-notifier): optional ScadaBridge:ApiKey in appsettings.json as a fallback to SCADABRIDGE_API_KEY (env still wins); README/env inventory/design row updated"
```
---
### Task 2: `docker/seed-delmia-sim.sh` — templates, composition, scripts, instance, deploy
**Classification:** standard (new script + two template scripts that must compile under the trust policy)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3
**Model:** sonnet
**Files:**
- Create: `docker/seed-delmia-sim.sh`
- Modify: `docker/README.md:96-97` (add the new script to the tree listing, one line)
**Step 1: Write the script**
```bash
#!/bin/bash
set -euo pipefail
# Seed an IN-MEMORY stand-in for a vd03-style machine with a composed DelmiaReceiver on
# site-a, so DelmiaNotifier can be exercised end-to-end on the docker rig without a Galaxy.
#
# What it creates (all static attributes — no data connection, no bindings):
# - Template DelmiaReceiverSim — the ten DelmiaReceiver attributes from prod
# (docs/api-specs/Delmia-Recipe-Download-API.md §5), ReadyFlag defaulting to true.
# - Template SimMachine — composes DelmiaReceiverSim as `DelmiaReceiver` and carries
# * ProcessRecipeDownload (Call) — prod's handshake ported to per-attribute
# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes).
# * DelmiaReceiverSimulator (ValueChange on DelmiaReceiver.RecipeDownloadFlag) —
# plays the Galaxy object: accepts unless DownloadPath ends in ".bad" or the work
# order is blank, writes RecipeProcessResult/Text, clears the trigger flag, then
# sets RecipeProcessedFlag=true.
# - Instance DelmiaSim01 on site-a (id 1), deployed.
#
# Not idempotent: names must be unique. To re-seed:
# scadabridge instance delete --id <id>; scadabridge template delete --id <SimMachine id>;
# scadabridge template delete --id <DelmiaReceiverSim id>
# Prerequisites: bash docker/deploy.sh; docker/seed-sites.sh; central healthy on :9000.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
SITE_ID="${SITE_ID:-1}"
INSTANCE_NAME="${INSTANCE_NAME:-DelmiaSim01}"
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
echo "1/6 Creating template DelmiaReceiverSim..."
RECV_ID=$("$CLI" template create "${A[@]}" --name DelmiaReceiverSim \
--description "In-memory stand-in for the Galaxy DelmiaReceiver object (ten static attributes)" | json_id)
echo " id=$RECV_ID"
echo "2/6 Adding the ten receiver attributes (static)..."
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeProcessedFlag --data-type Boolean --value false --description "Cleared by the caller, set true by the receiver when done" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeProcessResult --data-type Boolean --value false --description "Outcome -> Result" > /dev/null
for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name "$s" --data-type String --description "Recipe-download payload / outcome text" > /dev/null
done
echo "3/6 Creating template SimMachine + composing DelmiaReceiver..."
MACHINE_ID=$("$CLI" template create "${A[@]}" --name SimMachine \
--description "vd03-style machine stand-in: composes DelmiaReceiverSim as DelmiaReceiver; in-memory only" | json_id)
"$CLI" template composition add "${A[@]}" --template-id "$MACHINE_ID" --composed-template-id "$RECV_ID" --instance-name DelmiaReceiver > /dev/null
echo " id=$MACHINE_ID"
echo "4/6 Adding ProcessRecipeDownload (Call) — prod handshake on static attributes..."
read -r -d '' PROCESS_CODE <<'CS' || true
// Port of prod CvdReactor.ProcessRecipeDownload for STATIC attributes: WriteBatchAndWaitAsync only
// accepts data-sourced attributes, so the payload is written with SetAsync and the handshake waited
// with WaitAsync (same trigger/response flags, same 25 s budget, same result mapping).
var recv = Children["DelmiaReceiver"];
System.Func<object?,bool> IsTrue = v => { var s = v?.ToString(); return (v is bool b && b) || s == "1" || string.Equals(s, "true", System.StringComparison.OrdinalIgnoreCase); };
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
if (!IsTrue(await recv.Attributes.GetAsync("ReadyFlag")))
return new { Result = false, ResultText = "Receiver ready flag not set to true" };
try {
await recv.Attributes.SetAsync("RecipeProcessedFlag", false);
await recv.Attributes.SetAsync("DownloadPath", P("DownloadPath"));
await recv.Attributes.SetAsync("WorkOrderNumber", P("WorkOrderNumber"));
await recv.Attributes.SetAsync("PartNumber", P("PartNumber"));
await recv.Attributes.SetAsync("JobStepNumber", P("JobStepNumber"));
await recv.Attributes.SetAsync("Username", P("Username"));
await recv.Attributes.SetAsync("RecipeDownloadFlag", true);
if (!await recv.Attributes.WaitAsync("RecipeProcessedFlag", true, System.TimeSpan.FromSeconds(25)))
return new { Result = false, ResultText = "Timeout waiting for recipe to be processed" };
return new {
Result = IsTrue(await recv.Attributes.GetAsync("RecipeProcessResult")),
ResultText = (await recv.Attributes.GetAsync("RecipeProcessResultText"))?.ToString() ?? ""
};
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
CS
"$CLI" template script add "${A[@]}" --template-id "$MACHINE_ID" --name ProcessRecipeDownload --trigger-type Call \
--parameters '{"type":"object","properties":{"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--code "$PROCESS_CODE" > /dev/null
echo "5/6 Adding DelmiaReceiverSimulator (ValueChange on DelmiaReceiver.RecipeDownloadFlag)..."
read -r -d '' SIM_CODE <<'CS' || true
// Plays the Galaxy DelmiaReceiver object. Fires on every write to RecipeDownloadFlag (root-scope
// trigger, canonical name) and acts only on the rising edge; its own reset to false re-triggers
// this script, which then returns at the guard.
var recv = Children["DelmiaReceiver"];
var flag = await recv.Attributes.GetAsync("RecipeDownloadFlag");
var fs = flag?.ToString();
if (!((flag is bool b && b) || fs == "1" || string.Equals(fs, "true", System.StringComparison.OrdinalIgnoreCase))) return null;
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString() ?? "";
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
var ok = path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase);
await recv.Attributes.SetAsync("RecipeProcessResult", ok);
await recv.Attributes.SetAsync("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo : "Simulated reject: bad path or missing work order");
await recv.Attributes.SetAsync("RecipeDownloadFlag", false);
await recv.Attributes.SetAsync("RecipeProcessedFlag", true);
return null;
CS
"$CLI" template script add "${A[@]}" --template-id "$MACHINE_ID" --name DelmiaReceiverSimulator --trigger-type ValueChange \
--trigger-config '{"attributeName":"DelmiaReceiver.RecipeDownloadFlag"}' --code "$SIM_CODE" > /dev/null
echo "6/6 Creating + deploying instance $INSTANCE_NAME on site $SITE_ID..."
INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$MACHINE_ID" --site-id "$SITE_ID" | json_id)
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID"
echo ""
echo "DELMIA_SIM_TEMPLATE_ID=$RECV_ID SIM_MACHINE_TEMPLATE_ID=$MACHINE_ID INSTANCE_ID=$INSTANCE_ID INSTANCE_NAME=$INSTANCE_NAME"
echo "Verify: $CLI debug snapshot --id $INSTANCE_ID --url http://localhost:9000 --username multi-role --password password"
```
**Step 2: Make it executable and run it against the rig**
Run: `chmod +x docker/seed-delmia-sim.sh && bash docker/seed-delmia-sim.sh 2>&1 | tail -15`
Expected: six numbered lines, a deploy reply without `"error"`, and the final `DELMIA_SIM_TEMPLATE_ID=… INSTANCE_ID=…` line. Record `INSTANCE_ID` for Task 4.
If `template script add` fails with a trust-policy or compile error, the message names the offending symbol — fix the script text, do **not** widen the trust policy. If `instance deploy` reports a pre-deployment validation failure mentioning bindings, a data-source was set by mistake: static attributes must be created **without** `--data-source`.
**Step 3: Prove the handshake works site-side before any HTTP is involved**
Run (bash):
```bash
CLI=src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge
$CLI debug snapshot --id <INSTANCE_ID> --url http://localhost:9000 --format=json --username multi-role --password password | jq '.attributes // . | with_entries(select(.key|startswith("DelmiaReceiver.")))'
```
Expected: ten `DelmiaReceiver.*` entries, `ReadyFlag` true, `RecipeProcessedFlag` false. (The snapshot JSON shape is the CLI's — adapt the `jq` path to what comes back; the point is that all ten attributes exist under the `DelmiaReceiver.` prefix.)
**Step 4: Add the script to the docker tree listing**
`docker/README.md` line 97: after the `seed-live-probe.sh` row add
`├── seed-delmia-sim.sh # Seed the in-memory Delmia machine stand-in (DelmiaSim01) on site-a`
**Step 5: Commit**
```bash
git add docker/seed-delmia-sim.sh docker/README.md
git commit -m "chore(docker): seed-delmia-sim.sh — in-memory SimMachine + composed DelmiaReceiver stand-in on site-a with a ValueChange simulator playing the Galaxy side"
```
---
### Task 3: `docker/seed-delmia-api.sh` — inbound method + scoped API key
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2
**Model:** sonnet
**Files:**
- Create: `docker/seed-delmia-api.sh`
- Modify: `docker/README.md` (one tree-listing line under the Task 2 line)
**Step 1: Write the script**
```bash
#!/bin/bash
set -euo pipefail
# Seed the DelmiaRecipeDownload inbound method (prod's script verbatim, minus its dead `suf`
# line — see docs/api-specs/Delmia-Recipe-Download-API.md §4) and one API key scoped to it.
# Prints the one-time token as DELMIA_API_KEY=... — capture it; it cannot be retrieved again.
# Not idempotent (method name must be unique): delete with `api-method delete --id`.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
KEY_NAME="${KEY_NAME:-delmia-notifier}"
read -r -d '' METHOD_CODE <<'CS' || true
try {
System.Func<string, object> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var code = (P("MachineCode") as string ?? "").Trim();
if (string.IsNullOrEmpty(code)) return new { Result = false, ResultText = "MachineCode is required" };
return await Route.To(code).Call("ProcessRecipeDownload", new {
DownloadPath = P("DownloadPath"),
WorkOrderNumber = P("WorkOrderNumber"),
PartNumber = P("PartNumber"),
JobStepNumber = P("JobStepNumber"),
Username = P("Username"),
});
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
CS
echo "1/2 Creating inbound method DelmiaRecipeDownload (timeout 45 s)..."
METHOD_JSON=$("$CLI" api-method create "${A[@]}" --name DelmiaRecipeDownload --timeout 45 \
--parameters '{"type":"object","properties":{"MachineCode":{"type":"string"},"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--script "$METHOD_CODE")
echo " $(echo "$METHOD_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("id=%s name=%s" % (d["id"], d["name"]))')"
echo "2/2 Creating API key '$KEY_NAME' scoped to DelmiaRecipeDownload..."
KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods DelmiaRecipeDownload 2>/dev/null)
TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "DELMIA_API_KEY=$TOKEN"
```
> Note: `security api-key create` prints the token via `PrintCreatedKey` in table mode; with `--format=json` the raw `{keyId, token}` document is emitted and parsed above. If the json branch still prints the human lines, grep the `sbk_` token instead: `TOKEN=$(echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1)`.
**Step 2: Run it and keep the token OUT of git**
Run: `chmod +x docker/seed-delmia-api.sh && bash docker/seed-delmia-api.sh`
Expected: `id=<n> name=DelmiaRecipeDownload` then `DELMIA_API_KEY=sbk_…`. Save the token to the scratchpad only (e.g. `$SCRATCH/delmia.env` with `export DELMIA_API_KEY=…`). Never write it into `src/**/appsettings.json`.
**Step 3: Smoke the method with curl (no notifier yet)**
```bash
curl -s -X POST http://localhost:9000/api/DelmiaRecipeDownload -H "X-API-Key: $DELMIA_API_KEY" -H "Content-Type: application/json" \
-d '{"MachineCode":"DelmiaSim01","DownloadPath":"C:\\recipes\\wo1.nc","WorkOrderNumber":"WO-1","PartNumber":"PN-1","JobStepNumber":"0100","Username":"curl"}'
```
Expected: `{"Result":true,"ResultText":"Simulated download accepted for WO-1"}`. Then the reject path (`"DownloadPath":"C:\\recipes\\x.bad"`) → `Result:false`, `Simulated reject…`; and a bogus key → HTTP 401 `{"error":"Invalid or missing API key"}`. If the first call returns `Timeout waiting for recipe to be processed`, the simulator did not fire: check `scadabridge debug snapshot` for `DelmiaReceiver.RecipeDownloadFlag` (should have been reset to false) and the site-a-a container log for a compile error on `DelmiaReceiverSimulator`.
**Step 4: Commit**
```bash
git add docker/seed-delmia-api.sh docker/README.md
git commit -m "chore(docker): seed-delmia-api.sh — DelmiaRecipeDownload inbound method (prod script) + scoped delmia-notifier API key"
```
---
### Task 4: `docker/verify-delmia-notifier.sh` — notifier end-to-end + central failover drill
**Classification:** high-risk (drives `docker stop` on a live rig node; interprets failover timing)
**Estimated implement time:** ~5 min to write, ~6 min to run (two drills × ~1 min settle)
**Parallelizable with:** none (needs Tasks 1–3)
**Model:** **opus** — run interactively, read the outputs, and decide whether a `NO` is an expected takeover-window answer or a real fault before proceeding.
**Files:**
- Create: `docker/verify-delmia-notifier.sh`
- Modify: `docker/README.md` (one tree-listing line)
**Step 1: Write the script**
```bash
#!/bin/bash
set -euo pipefail
# End-to-end DelmiaNotifier verification against the docker rig, including an ACTIVE central
# crash + rejoin. Requires: seed-delmia-sim.sh + seed-delmia-api.sh already run, and
# DELMIA_API_KEY exported (the token printed by seed-delmia-api.sh).
#
# The notifier is run as the managed build (dotnet WWNotifier.dll) from a scratch copy so the
# API key is written to THAT copy's appsettings.json (ScadaBridge:ApiKey — Task 1) and never
# into the repo. BaseUrls lists the two central nodes DIRECTLY (not Traefik) so the notifier's
# own connect-failure failover is what gets exercised.
#
# Expected outcomes:
# A baseline (active listed first) -> YES
# B standby listed first (standby relays) -> YES (StandbyForwardPeerEndpoints)
# C bad key -> NO (HTTP 401, no failover: 401 is an answer)
# D active STOPPED, before takeover completes -> NO or YES — informational only. A NO here is the
# documented connect-failure-only design (standby answers 503 ACTIVE_NODE_UNREACHABLE).
# E active STOPPED, after survivor /health/active=200 -> YES (first URL refused -> failover)
# F victim restarted and rejoined (members=2) -> YES
# G reject path (DownloadPath *.bad) -> NO + "Simulated reject"
: "${DELMIA_API_KEY:?export DELMIA_API_KEY=sbk_... first}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"; PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUN_DIR="${RUN_DIR:-${TMPDIR:-/tmp}/delmia-notifier-run}"
MACHINE="${MACHINE:-DelmiaSim01}"
A_URL=http://localhost:9001; B_URL=http://localhost:9002
echo "== build notifier + stage a runnable copy in $RUN_DIR"
dotnet build "$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier" -c Debug -nologo -v q
rm -rf "$RUN_DIR"; mkdir -p "$RUN_DIR"; cp -R "$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/bin/Debug/net10.0/." "$RUN_DIR/"
write_cfg() { # $1 = BaseUrls, $2 = ApiKey
printf '{"ScadaBridge":{"BaseUrls":"%s","TimeoutSeconds":30,"LogPath":"delmia-notifier.log","ApiKey":"%s"}}\n' "$1" "$2" > "$RUN_DIR/appsettings.json"
}
notify() { # $1 = label, $2 = expected YES|NO, rest = extra args; prints stdout + exit code
local label=$1 expect=$2; shift 2
local out rc; set +e; out=$(env -u SCADABRIDGE_API_KEY dotnet "$RUN_DIR/WWNotifier.dll" -m "$MACHINE" -d 'C:\recipes\wo1.nc' -w "WO-$label" -p PN-1 -s 0100 -u drill "$@" 2>/dev/null); rc=$?; set -e
local first; first=$(echo "$out" | head -1)
printf '%-3s stdout=%-4s exit=%-3s | %s\n' "$label" "$first" "$rc" "$(echo "$out" | sed -n 2p)"
[ "$first" = "$expect" ] || { echo " ^^ EXPECTED $expect"; return 1; }
}
active() { curl -sf -o /dev/null "$A_URL/health/active" && echo a || { curl -sf -o /dev/null "$B_URL/health/active" && echo b; }; }
url_of() { [ "$1" = a ] && echo "$A_URL" || echo "$B_URL"; }
container_of() { [ "$1" = a ] && echo scadabridge-central-a || echo scadabridge-central-b; }
other() { [ "$1" = a ] && echo b || echo a; }
wait_for() { # $1 url $2 path $3 seconds
local i; for i in $(seq 1 "$3"); do curl -sf -o /dev/null "$1$2" && return 0; sleep 1; done; return 1; }
ACT=$(active); STB=$(other "$ACT")
echo "== active=central-$ACT standby=central-$STB"
echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES
echo "== B standby first (relay)"; write_cfg "$(url_of "$STB"),$(url_of "$ACT")" "$DELMIA_API_KEY"; notify B YES
echo "== C bad key"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "sbk_bogus_bogus"; notify C NO || true
write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"
echo "== D/E stop the ACTIVE central ($(container_of "$ACT"))"; docker stop "$(container_of "$ACT")" > /dev/null
T0=$(date +%s)
echo "-- D immediate attempt (informational):"; notify D NO || notify D YES || true
echo "-- waiting for survivor $(url_of "$STB")/health/active (auto-down ~25-30 s)..."
wait_for "$(url_of "$STB")" /health/active 90 || { echo "FAIL: survivor never became active"; exit 1; }
echo " takeover after $(( $(date +%s) - T0 )) s"
echo "== E after takeover (first URL refused -> failover)"; notify E YES
echo "== F restart victim + wait for rejoin"; docker start "$(container_of "$ACT")" > /dev/null
wait_for "$(url_of "$ACT")" /health/ready 120 || { echo "FAIL: victim never ready"; exit 1; }
for i in $(seq 1 60); do curl -s "$(url_of "$STB")/health/ready" | grep -q '"members":2\|members=2\|Up.*Up' && break; sleep 2; done
notify F YES
echo "== G reject path"; notify G NO -d 'C:\recipes\x.bad' || true
echo "== done. Notifier log: $RUN_DIR/delmia-notifier.log"
```
> The `members` probe in F is deliberately loose — check what `/health/ready` actually prints on this build (`curl -s localhost:9001/health/ready`) and tighten the grep to the real field before relying on it.
**Step 2: Run it**
```bash
chmod +x docker/verify-delmia-notifier.sh
export DELMIA_API_KEY=sbk_… # from Task 3
bash docker/verify-delmia-notifier.sh 2>&1 | tee "$SCRATCH/verify-delmia.log"
```
Expected: A, B, E, F print `stdout=YES exit=0`; C and G print `stdout=NO`; D is informational (record which it was and the takeover seconds). The victim rejoins as **standby** (`/health/active` 503 on it, 200 on the survivor). Any other outcome is a real finding — capture the notifier log line (`attempt <url>: …`) and the central log (`docker logs --since 5m scadabridge-central-<x> | grep -i delmia`) before changing anything.
**Step 3: Confirm the audit trail**
```bash
CLI=src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge
$CLI audit query --channel ApiInbound --since 15m --url http://localhost:9000 --format=json --username multi-role --password password | grep DelmiaRecipeDownload | jq -c '{t:.occurredAtUtc, kind, status, http:.httpStatus, node:.sourceNode, actor, resp:.responseSummary}'
```
Expected: one `InboundRequest` row per YES/NO business answer, **exactly one row for B** (the standby relay must not double-audit — `InboundApiEndpointFilter.RelayedToActiveItemKey`), an `InboundAuthFailure` for C, and `sourceNode` showing the node that executed each call (B and E should show the node that was active at the time).
**Step 4: Commit the script**
```bash
git add docker/verify-delmia-notifier.sh docker/README.md
git commit -m "test(docker): verify-delmia-notifier.sh — notifier end-to-end on the rig incl. standby relay, active-central crash/takeover and rejoin"
```
---
### Task 5: Record results + spec/doc updates
**Classification:** trivial (docs only)
**Estimated implement time:** ~4 min
**Parallelizable with:** none (needs Task 4's numbers)
**Model:** sonnet
**Files:**
- Modify: `docs/api-specs/Delmia-Recipe-Download-API.md` (new §8 "Docker rig stand-in", pointing at the two seed scripts, the static-attribute port, and the verified outcomes A–G with the measured takeover time)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md` (`## Manual smoke test` → add the rig recipe: run the three scripts, `export DELMIA_API_KEY`, expected YES/NO table)
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (one new row: "prod `ProcessRecipeDownload` still addresses `Children[\"DelmiaRecv\"]` after the 2026-06-27 rename — fix + redeploy Z28061/Z28062/Z28061Sim; QA unverified" — only if a row for it does not already exist)
- Modify: `CLAUDE.md` — in the `### Templates & Deployment` / DelmiaNotifier context, one sentence: "Rig stand-in: `docker/seed-delmia-sim.sh` + `seed-delmia-api.sh` + `verify-delmia-notifier.sh` (static-attribute port of the prod handshake — `WriteBatchAndWaitAsync` rejects static attributes, use `SetAsync`/`WaitAsync`)."
**Step 1: Write the sections** using the numbers from Task 4's log (takeover seconds, D's outcome, audit row count).
**Step 2: Check no stale claims remain**
Run: `grep -rn "never read from or written to a file\|env-only by design" src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier docs env_variables.md CLAUDE.md`
Expected: no hits (Task 1 removed the README/env ones; anything left here is stale and must be updated).
**Step 3: Commit**
```bash
git add docs/api-specs/Delmia-Recipe-Download-API.md src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md docs/plans/2026-07-08-deferred-work-register.md CLAUDE.md
git commit -m "docs: Delmia rig stand-in + notifier verification results (A-G incl. active-central takeover); register row for prod DelmiaRecv path defect"
```
---
### Task 6: Final integration review
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Model:** **fable** (this session) — read `git log --oneline main..HEAD` + `git diff main`, re-run `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`, and check specifically: (1) no `sbk_` token anywhere in the diff (`git diff main | grep -n 'sbk_[A-Za-z0-9]'` must return only the placeholder in the bad-key test), (2) the env-over-file precedence is asserted by a test, (3) the seed scripts leave the rig in a state `docker/README.md` describes, (4) Task 4's log shows E and F as YES with the victim rejoined as standby. Then decide with the user whether to push (`/pushit`) — the plan does not push.
---
## Out of scope (deliberately)
- Fixing prod vd03's `Children["DelmiaRecv"]` defect or making the prod method idempotent — recorded in the deferred-work register (Task 5); separate change with its own deployment record.
- Extending `WriteBatchAndWaitAsync` to static attributes.
- Site-pair failover (the user asked for the 2-node **central** cluster); `docker/failover-drill.sh` already covers the general case.
- Native AOT publish of the notifier (Windows-only); the managed build is what the rig exercises.
@@ -0,0 +1,69 @@
{
"planPath": "docs/plans/2026-09-02-delmia-sim-rig-verification.md",
"tasks": [
{
"id": 0,
"subject": "Task 0: Preflight \u2014 rig healthy, CLI built, notifier tests green",
"status": "completed",
"model": "sonnet"
},
{
"id": 1,
"subject": "Task 1: DelmiaNotifier \u2014 optional ApiKey in appsettings.json, env var wins",
"status": "completed",
"blockedBy": [
0
],
"model": "sonnet",
"review": "opus"
},
{
"id": 2,
"subject": "Task 2: docker/seed-delmia-sim.sh \u2014 templates, composition, scripts, instance, deploy",
"status": "completed",
"blockedBy": [
0
],
"model": "sonnet"
},
{
"id": 3,
"subject": "Task 3: docker/seed-delmia-api.sh \u2014 inbound method + scoped API key",
"status": "completed",
"blockedBy": [
0
],
"model": "sonnet"
},
{
"id": 4,
"subject": "Task 4: docker/verify-delmia-notifier.sh \u2014 notifier end-to-end + central failover drill",
"status": "completed",
"blockedBy": [
1,
2,
3
],
"model": "opus"
},
{
"id": 5,
"subject": "Task 5: Record results + spec/doc updates",
"status": "completed",
"blockedBy": [
4
],
"model": "sonnet"
},
{
"id": 6,
"subject": "Task 6: Final integration review",
"status": "completed",
"blockedBy": [
5
],
"model": "fable"
}
],
"lastUpdated": "2026-09-02T17:10:00Z"
}
@@ -129,3 +129,28 @@ $env:SCADABRIDGE_API_KEY = "sbk_..."
This mirrors the earlier `curl` verification of `POST /api/DelmiaRecipeDownload` with the
`X-API-Key` header.
### Docker rig recipe (no Galaxy needed)
The local rig (`docker/`) carries an in-memory stand-in for a vd03-style machine, so the whole
path — notifier → inbound method → site `Call` script → simulated `DelmiaReceiver` handshake — runs
on a laptop, including a 2-node central failover. Verified 2026-09-02
(`docs/api-specs/Delmia-Recipe-Download-API.md` §8):
```bash
bash docker/seed-delmia-sim.sh # SimMachine/DelmiaReceiverSim templates + DelmiaSim01 on site-a, deployed
bash docker/seed-delmia-api.sh # DelmiaRecipeDownload method + scoped key; prints DELMIA_API_KEY=sbk_... ONCE
export DELMIA_API_KEY=sbk_...
bash docker/sync-inbound-api-keys.sh # keys are a per-node store: mirror central-a -> central-b (register row 108)
bash docker/verify-delmia-notifier.sh # runs the managed build from a scratch copy; the key goes in THAT copy's appsettings.json
```
| Case | Setup | Expected |
|------|-------|----------|
| A | active node first in `BaseUrls` | `YES` |
| B | standby first (standby relays to the active) | `YES` |
| C | bad key | `NO` + `HTTP 401` (an answer is final — no failover) |
| D | active `docker kill`ed, attempt inside the takeover window | `NO` + `HTTP 503` — expected: connect-failure-only failover, the survivor is not yet active |
| E | survivor `/health/active` = 200 (~27 s) | `YES` (first URL refused → second URL) |
| F | victim restarted and rejoined as standby | `YES` (victim relays) |
| G | `DownloadPath` ending in `.bad` | `NO` + `Simulated reject: bad path or missing work order` |