Files
ScadaBridge/docs/plans/2026-09-06-flow-debugger-outbound-calls.md
T
Joseph Doherty 3a933e3f68 docs(plans): Flow Debugger outbound calls — plan + mockup for API/DB/notification nodes on an External lane
Gap verified 2026-09-06: the projector and predictor already know the three outbound
channels, but the live FlowTraceCollector is fed only by EmitTrace (writes/waits) and
the replay query filters to ScriptTrace + the inbound row, so a run leaves every
predicted outbound node as a ghost. Central method-script Database.* calls are not
audited at all.

Plan (11 tasks): three step kinds ApiCall/DbCall/Notify on a fourth External lane;
one node per operation with the cached/notify lifecycle folded in (Pending/Parked
outcomes, Lifecycle tab); sync calls in the chain, async ones on dashed branch edges;
site rows ride the existing collector, replay filter widens, central DB helper gets
DbOutbound rows; the page refreshes pending outcomes via the existing Replay command;
spawn edges styled and Response linked from the route call (the "two parallel paths"
question). Mockup: docs/plans/…-mockup.html, published as a Claude artifact.

Claude-Session: https://claude.ai/code/session_01GSLkkHKz9JrAtmJjmQK3cP
2026-09-06 01:33:41 -04:00

236 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Flow Debugger — Outbound Calls (API · Database · Notification) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Show every `ExternalSystem.*`, `Database.*` and `Notify.Send` a traced flow makes as first-class nodes on a fourth **External** swimlane, in both Live and Replay, with a cached call's or notification's later delivery lifecycle folded into the node instead of drawn as four separate steps.
**Architecture:** No new audit rows for the site side — the outbound rows already exist in the execution tree (`ApiOutbound` / `DbOutbound` / `Notification` channels, stamped with the run's `ExecutionId`); the gap is that neither the live `FlowTraceCollector` nor the replay query ever includes them. The site runtime routes those rows through the collector it already holds, the replay filter widens to the three channels, and `FlowGraphBuilder` groups a cached operation's rows by `CorrelationId` into one step carrying a `Lifecycle` list. The Flow Debugger page keeps a Live run's pending nodes fresh by re-reading the tree through the existing Replay command until every node is terminal or a bounded window closes. One real audit gap is closed on the way: central method scripts' `Database.*` calls (`InboundDatabaseHelper`) are unaudited today and get `DbOutbound` rows.
**Tech Stack:** .NET 10, Akka.NET site runtime, `ZB.MOM.WW.Audit` `AuditEvent`, Blazor Server custom SVG (`FlowGraphView`), Roslyn syntax predictor, xunit + NSubstitute + bUnit.
**Mockup:** `docs/plans/2026-09-06-flow-debugger-outbound-calls-mockup.html` (published as a Claude artifact: https://claude.ai/code/artifact/712697e3-c9c9-42ce-80fc-d05065c183bb). Design origin: `docs/plans/2026-09-05-flow-debugger-design.md` §4 / §7.2; this plan is an addendum to it.
---
## Why (the gap, verified 2026-09-06)
- `FlowStepProjector.ResolveKind` already maps `ApiOutbound` / `DbOutbound` / `Notification` rows to `FlowStepKind.External`, and `FlowShapePredictor` already emits `External` nodes for `ExternalSystem.Call/CachedCall`, `Database.*`, `Notify.Send`. So the **prediction** shows them.
- **Live** never returns them: `ScriptRuntimeContext.EmitTrace` (`ScriptRuntimeContext.cs:363-392`) is the only feeder of `_traceCollector`, and it is called for `AttributeWrite` / `AttributeWait` only (the `ScriptRun` row is added by `ScriptRunLauncher`). The outbound rows are built at `ScriptRuntimeContext.cs:1286` (ES `CachedSubmit`), `:1404` (`ApiCallCached` Attempted), `:1472` (`CachedResolve`), `:1651` (sync `ApiCall`), `:2015` / `:2067` (DB cached rows), `:2518` (`NotifySend`) and in `AuditingDbCommand.cs:479` (sync `DbWrite`) — all go straight to the site `IAuditWriter`.
- **Replay** filters the tree to `Channel == ScriptTrace || (ApiInbound && InboundRequest)` (`AuditLogRepository.cs` `GetTraceEventsAsync`, the `.Where` right after the recursive CTE).
- Result today: after a run, every predicted outbound node is left as a **ghost** ("predicted, never happened") even when the call ran. Worse than omitting it.
- Central method scripts have a `Database` helper (`InboundScriptExecutor.cs:554`, `InboundDatabaseHelper.cs`) that writes **no audit row at all** — a real audit-scope gap (`Component-AuditLog.md` says outbound DB is in scope), independent of the debugger.
## Row inventory (what the tree already holds per script call)
| Script call | Rows (channel / kind / status) | Written by | `CorrelationId` |
|---|---|---|---|
| `ExternalSystem.Call` (sync) | `ApiOutbound` / `ApiCall` / `Delivered` or `Failed`; request + response summaries, `DurationMs` | site, inline | null |
| `ExternalSystem.CachedCall` | `CachedSubmit`/`Submitted` → `ApiCallCached`/`Forwarded` or `Attempted` (per retry) → `CachedResolve`/`Delivered`·`Failed`·`Parked`·`Discarded` | site: submit inline, the rest from the S&F forwarder **minutes later** | `TrackedOperationId` |
| `Database.Query/Execute` (sync) | `DbOutbound` / `DbWrite` / `Delivered`·`Failed`; `Extra = {op, rowsAffected|rowsReturned}` | site `AuditingDbCommand` | null |
| `Database.CachedWrite` | `CachedSubmit` → `DbWriteCached`/`Attempted` → `CachedResolve` | site, as above | `TrackedOperationId` |
| `Notify.Send` | site `Notification` / `NotifySend` / `Submitted` (target = list name) → central `NotifyDeliver` / `Attempted`, `Delivered`·`Parked`·`Discarded` | site inline, then `NotificationOutboxActor` (central, `executionId: notification.OriginExecutionId`) | `NotificationId` |
| central `Database.*` (method script) | **none today** → Task 4 adds `DbOutbound` / `DbWrite` | central `ICentralAuditWriter` | null |
## Decisions
| # | Decision | Rationale / rejected alternative |
|---|---|---|
| D1 | **Three kinds replace `External`:** `FlowStepKind.ApiCall`, `DbCall`, `Notify` (eyebrows `API CALL`, `DATABASE`, `NOTIFY`). `External` is removed (internal projection enum; nothing serializes `FlowStep`). | Distinct kinds make the predicted↔real overlay match by kind, and the eyebrow tells the operator what it is without reading the title. |
| D2 | **Fourth lane `FlowLane.External`** (index 3; sublabel = the distinct targets touched, e.g. `Delmia · MesDb · Planners`). | The lane *is* the boundary: like Tags, it holds actors outside ScadaBridge. Putting the node in the caller's lane would hide that a hop left the system. Design v1 said "three lanes fixed"; this is the v1.1 amendment. |
| D3 | **One node per operation.** Rows sharing `(Channel, CorrelationId)` fold into one `FlowStep`; a sync row (null correlation) is a group of one. `OccurredAtUtc` = first row; `DurationMs` = sync row's own, or submit→terminal wall time for cached; `Outcome` from the **latest** row: `Delivered`→`Ok`, `Failed`→`Failed`, `Parked`/`Discarded`→**new `FlowStepOutcome.Parked`**, anything non-terminal→**new `FlowStepOutcome.Pending`**. New additive `FlowStep.Lifecycle : IReadOnlyList<FlowLifecycleRow>?` (`EventId, Kind, Status, OccurredAtUtc, Node, ErrorMessage, DurationMs`) drives a detail-panel tab. | Four boxes per cached call would triple the graph width and say nothing the status pill doesn't. |
| D4 | **Sync calls sit in the execution chain; cached/notify branch off it.** The builder's chain edge skips a step whose kind is `Notify` or whose first row is `CachedSubmit`, and instead adds an edge from the preceding step to it flagged **`Async`** (new `FlowEdge.Async`, dashed). | A script blocks on a sync call, so in-chain is truthful; `CachedCall`/`Notify.Send` return immediately, so the run continues without them — same fork semantics the graph already uses for trigger spawns. |
| D5 | **Live returns what the run emitted; the page refreshes the rest.** Site: the outbound rows built in `ScriptRuntimeContext`/`AuditingDbCommand` pass through `_traceCollector.Add` before the audit writer (same as `EmitTrace`). Central: the inbound DB rows go through the `RouteHelper` trace sink already wired in `FlowDebugRunner.cs:106`. Later lifecycle rows (Forwarded/Attempted/Resolve/NotifyDeliver) are not in the reply; after a Live run whose graph has any `Pending` step, the page polls **the existing Replay command** every 5 s for up to 2 min and merges, and offers `Refresh outcomes` manually. | No new management command, no site push. Replay already returns the whole tree. `PollInterval`/`PendingRefreshWindow` are page constants, not options — nothing operational depends on them. |
| D6 | **Replay filter widens** to `ScriptTrace ∪ ApiOutbound ∪ DbOutbound ∪ Notification ∪ (ApiInbound & InboundRequest)`. Site-side `PullTraceEvents` (`SqliteAuditWriter.ReadByExecutionIdsAsync`) filters by execution id only — verify, no change expected. | Rows are already in the tree with the right ids; this is the one-line half of the fix. |
| D7 | **Central `Database.*` gets audited** — `InboundDatabaseHelper` takes an optional `Action<AuditEvent>` and an `ICentralAuditWriter`, emits `DbOutbound`/`DbWrite` with the request's `ExecutionId`, `Extra = {op, rows…}`, SQL as `RequestSummary` (post-redaction rules as the site's). | Closes an audit-scope gap; the debugger merely makes it visible. |
| D8 | **Predictor** emits the three kinds on the `External` lane with titles `Delmia.AckRecipe` / `MesDb · CachedWrite` / `Notify Planners`, and its edge to a cached/notify node is `Async`. | Keeps the overlay honest: a predicted async branch renders like a real one. |
| D9 | **Spawn edges get their own style, and Response links from the inbound execution's last step** (the question raised on 2026-09-06 about the "two parallel paths"). New `FlowEdge.Spawn` (edge from a parent step to a child execution's first step that is *not* a route call) renders with a small ⚡ marker; `Response` is linked from the last step whose `ExecutionId == inbound.ExecutionId` (the `RouteCall`), not from the globally last step. | The fork was real (a write both continued the run and fired a trigger) but unreadable; the Response edge from a cascaded write was simply wrong. |
Out of scope (deferred, note in register): Retry/Discard actions on a parked node (Site Calls page owns that); a `?correlationId=` deep link on the Site Calls / Notification report pages; per-attempt request/response diff.
## Node anatomy (all three kinds)
```
┌──────────────────────────────┐
│ API CALL 212 ms │ eyebrow · duration (sync) or "+4.1 s" (submit→delivered)
│ Delmia.AckRecipe │ title = target (System.Method / Connection / List)
│ ● 200 · sync │ pill = latest status; "sync" | "cached" | "notify"
└──────────────────────────────┘
pills: Ok (green) Pending (amber, pulsing ring while the page polls) Parked (amber, solid) Failed (red)
```
---
## Task 0: Commons — kinds, lane, outcomes, lifecycle row
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (everything depends on it)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStep.cs` (enums at lines 4-40; record `FlowStep` at ~56-73)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraph.cs:8` (`FlowEdge`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Debugging/FlowStepProjectorTests.cs` (exists — extend)
**Contract:** `FlowLane` gains `External = 3`. `FlowStepKind`: remove `External`, add `ApiCall`, `DbCall`, `Notify` (keep `Unknown`). `FlowStepOutcome` gains `Pending`, `Parked`. New `public sealed record FlowLifecycleRow(Guid EventId, string Kind, string Status, DateTimeOffset OccurredAtUtc, string? Node, string? ErrorMessage, int? DurationMs)`. `FlowStep` gains a trailing optional positional `IReadOnlyList<FlowLifecycleRow>? Lifecycle = null`. `FlowEdge` gains trailing `bool Async = false, bool Spawn = false`. Every existing constructor call compiles unchanged (defaults).
**Steps:** write the failing tests (enum members exist; `new FlowStep(...)` without `Lifecycle` still compiles; `new FlowEdge(a, b, false, false)` has `Async == false`), run `dotnet test tests/ZB.MOM.WW.ScadaBridge.Commons.Tests --filter FullyQualifiedName~FlowStep`, implement, re-run, commit — `git commit -am "feat(commons): Flow Debugger outbound kinds, External lane, Pending/Parked outcomes, FlowLifecycleRow"`.
## Task 1: Commons — `FlowStepProjector` maps outbound rows
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3, Task 4, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowStepProjector.cs` (`ResolveKind` ~47-70, `ResolveLane` ~73-79, `BuildSubtitle` ~93-110)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Debugging/FlowStepProjectorTests.cs`
**Contract:** `ResolveKind`: `ApiOutbound` → `ApiCall`, `DbOutbound` → `DbCall`, `Notification` → `Notify` (any kind in those channels). `ResolveLane`: the three kinds → `FlowLane.External`. Title = `evt.Target` (unchanged rule). `BuildSubtitle`: `ApiCall` → `"{status} · sync"` for `AuditKind.ApiCall`, `"{status} · cached"` otherwise; `DbCall` → `"{op} · {rowsAffected|rowsReturned} rows"` from `Extra` when present, else `"{status} · cached"`; `Notify` → `"{status}"`. `ResolveOutcome` for the three kinds: `Delivered`→`Ok`, `Failed`→`Failed`, `Parked`/`Discarded`→`Parked`, `Submitted`/`Forwarded`/`Attempted`→`Pending`. `InputJson` = `RequestSummary`, `OutputJson` = `ResponseSummary` (already).
**Tests (one per row shape, use `ScadaBridgeAuditEventFactory.Create`):** sync `ApiCall` Delivered → `ApiCall`/External/Ok/"Delivered · sync"; `CachedSubmit` in `DbOutbound` → `DbCall`/Pending/"Submitted · cached"; `DbWrite` with `extra {"op":"write","rowsAffected":3}` → "write · 3 rows"; `NotifySend` Submitted → `Notify`/Pending; `CachedResolve` Parked → `Parked`. Commit — `git commit -am "feat(commons): project outbound API/DB/notification rows onto the External lane"`.
## Task 2: Commons — `FlowGraphBuilder` groups operations, async branches, spawn edges, Response origin
**Classification:** high-risk (graph contract every surface reads)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 4, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Commons/Types/Debugging/FlowGraphBuilder.cs` (whole `Build`; rules doc-comment 10-24)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/Debugging/FlowGraphBuilderTests.cs` (exists — extend)
**Contract (new rule order):**
0. **Fold.** Before ordering, group projected steps of kind `ApiCall`/`DbCall`/`Notify` by `(Channel-derived kind, CorrelationId)`; null correlation = singleton. The folded step takes the **first** row's `EventId`/`OccurredAtUtc`/`InputJson`/`Title`, the **latest** row's `Outcome`/`Subtitle`/`OutputJson`/`ErrorMessage`, `DurationMs` = first row's when sync, else `(latest.OccurredAt − first.OccurredAt)` when latest is terminal, else null; `Lifecycle` = all rows ordered by time. `ExecutionId` = first row's (the script's).
1. Chain within execution **skipping** async steps (kind `Notify`, or first lifecycle row kind `CachedSubmit`); for each skipped step add `FlowEdge(prev → step, Async: true)` where `prev` is the chain step immediately before it in time.
2. Spawner link unchanged, but the edge is `Spawn: true` when the spawner is not a `RouteCall`.
3. Satisfied-wait echo edge unchanged (`External: true`).
4. Response: linked from the last step with `ExecutionId == inbound.ExecutionId` and kind ≠ Response (falls back to global last when none).
`KindRank`: `ApiCall`/`DbCall`/`Notify` = 5 (same slot `External` had).
**Tests:** (a) an ES cached call's 4 rows → one `ApiCall` step, `Lifecycle.Count == 4`, `Outcome == Ok`, `DurationMs == submit→resolve ms`, an `Async` edge from the preceding write, chain edge write→next write skips it; (b) a sync `ApiCall` row is in-chain (write → api → write) with no `Async` edge; (c) `NotifySend` + central `NotifyDeliver Parked` → one `Notify` step `Parked`; (d) submit-only → `Pending`, `DurationMs == null`; (e) Delmia fixture: spawn edge from the clearing write to the second simulator run is `Spawn: true`, the continuation edge is not; (f) Response edge starts at the `RouteCall`, not the last cascaded write; (g) existing builder tests still pass. Commit — `git commit -am "feat(commons): FlowGraphBuilder folds outbound lifecycles into one node; async/spawn edges; Response from the inbound execution"`.
## Task 3: Site runtime — outbound rows flow through the trace collector
**Classification:** high-risk (script runtime hot path)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 4, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs` (`EmitTrace` 363-392 — extract a `private void Collect(AuditEvent evt) => _traceCollector?.Add(evt);`; call it where the rows at 1286, 1404, 1472, 1651, 2015, 2067, 2518 are handed to the writer/telemetry)
- Modify: `src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/AuditingDbConnection.cs:147` and `AuditingDbCommand.cs:70-90, 479` — add an optional `Action<AuditEvent>? onAudit` ctor parameter threaded from the context's `Collect`
- Test: `tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ScriptRuntimeContextTraceEmissionTests.cs` (exists — extend)
**Contract:** With a collector present, each of the seven emission sites adds the **same** `AuditEvent` instance to the collector that it hands to the audit writer / telemetry (no duplicate construction). With no collector (untraced run, the production default) behaviour is byte-identical. The collector cap (`MaxTraceEventsPerRun` 500) applies unchanged. Rows the S&F forwarder writes later (Forwarded/Attempted/Resolve on a *different* thread after the run returned) MUST NOT touch the collector: guard with the collector's existing `Completed` flag (add it if absent — `ScriptRunLauncher` sets it when it snapshots `TraceEvents`).
**Tests:** sync `ExternalSystem.Call` (stub gateway) → collector holds `ScriptRun`+`ApiCall`; `CachedCall` → holds `CachedSubmit` only, and a simulated later resolve does not add; `Database.Execute` via `AuditingDbCommand` with a fake connection → `DbWrite` collected; `Notify.Send` → `NotifySend` collected; no collector → writer still receives every row. Commit — `git commit -am "feat(site-runtime): outbound API/DB/notification audit rows ride the flow-trace collector"`.
## Task 4: Central — audit `InboundDatabaseHelper` (`DbOutbound`) and feed the trace sink
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs` (ctor :52, `QuerySingleAsync` :67, `QueryAsync` :83, `ExecuteAsync` :113)
- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs:384` (construct the helper with `executionId`, `parentExecutionId`, the request's method name as actor context, `ICentralAuditWriter` from DI, and the route's trace sink)
- Modify: `src/ZB.MOM.WW.ScadaBridge.InboundAPI/RouteHelper.cs:163` (expose the sink so the executor can pass the same delegate)
- Test: `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperAuditTests.cs` (new)
**Contract:** Each helper call writes one `AuditChannel.DbOutbound` / `AuditKind.DbWrite` row via `ICentralAuditWriter` (best-effort, never throws into the script), `Status` `Delivered`/`Failed`, `Target = connectionName`, `Actor = "api:{methodName}"`, `ExecutionId`/`ParentExecutionId` from the request, `DurationMs`, `RequestSummary` = SQL + parameters (same 8 KB cap and redaction as the site's `AuditingDbCommand` — reuse `AuditPayloadPolicy` if the site one is shared; otherwise mirror the cap constant), `Extra = {"op":"read"|"write","rows…":n}`, `SourceNode` from `INodeIdentityProvider`. When a trace sink is present the same instance is passed to it first. Spec line for `Component-AuditLog.md` is in Task 10.
**Tests:** `ExecuteAsync` writes a `DbWrite` Delivered row with `rowsAffected`; a throwing gateway writes `Failed` with the error and rethrows to the script; audit writer faulting does not fail the call; sink receives the same instance. Commit — `git commit -am "feat(inbound-api): audit central method-script Database.* calls (DbOutbound) and feed the flow-trace sink"`.
## Task 5: Central replay filter + site pull verification
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 2, Task 3, Task 4
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs` (`GetTraceEventsAsync`, the `.Where(r => r.Channel == AuditChannel.ScriptTrace || …)` after the CTE)
- Verify only: `src/ZB.MOM.WW.ScadaBridge.AuditLog/Site/SqliteAuditWriter.cs:861` `ReadByExecutionIdsAsync` (no channel predicate — add a test pinning that)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTraceTests.cs` (exists from Task 12 of the base plan — extend), `tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Site/SqliteAuditWriterReadByExecutionIdsTests.cs` (extend)
**Contract:** replay returns rows whose channel ∈ {`ScriptTrace`, `ApiOutbound`, `DbOutbound`, `Notification`} plus the `InboundRequest` row, in `(OccurredAtUtc, EventId)` order; `SecuredWrite`/`Cluster`/`InboundAuthFailure` still excluded. Site pull returns an `ApiOutbound` row stored under the execution id.
Commit — `git commit -am "feat(audit): replay/pull include outbound API, DB and notification rows"`.
## Task 6: `FlowShapePredictor` — three kinds on the External lane, async edges
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7, Task 8
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/FlowShapePredictor.cs:380-402` (the `ExternalSystem`/`Database`/`Notify` branches; `PredictedEdge` gains `Async`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/FlowShapePredictorTests.cs`
**Contract:** `ExternalSystem.Call(s, m, …)` → `ApiCall`, lane External, title `"{s}.{m}"` (input-dependent when either is not a literal); `CachedCall` same kind, note `"cached"`, incoming edge `Async`; `Database.Execute/Query/QuerySingle(conn, …)` → `DbCall`, title `"{conn} · {member}"`; `Database.CachedWrite` → `DbCall`, note `"cached"`, `Async`; `Notify.Send(list, …)` → `Notify`, title `"Notify {list}"`, `Async`. `PredictedEdge(From, To, CrossesBoundary, Async = false)`; an `Async` edge does not advance the chain (the next node links from the previous non-async node), mirroring D4.
**Tests:** a site script with `var r = await ExternalSystem.CallAsync("Delmia","AckRecipe",…); await Database.CachedWriteAsync("MesDb", …); await Notify.SendAsync("Planners", …);` predicts `ScriptRun → ApiCall(in chain) ; Async→ DbCall ; Async→ Notify`, all lane External. Commit — `git commit -am "feat(script-analysis): predict outbound calls as ApiCall/DbCall/Notify on the External lane"`.
## Task 7: `FlowGraphView` — External lane, status pills, async/spawn edge styles, pending state
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 6, Task 8
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/FlowGraphView.razor` (lane loop `for lane < 3` → `LaneCount`; node body: add a status pill row for the three kinds; edge classes), `FlowGraphView.razor.cs` (`LaneCount = 4`; `Eyebrow` for the three kinds; sublabel for External = distinct `Target`s joined with ` · `, clipped; `EdgeClass` adds `async`, `spawn`; `NodeClass` adds `pending`, `parked`; `MarkerFor` returns `url(#fg-arrow-spawn)` for spawn edges), `FlowGraphView.razor.css` (`.fg-edge.async { stroke-dasharray: 5 4 }`, `.fg-edge.spawn` marker path with a small bolt, `.fg-pill` ok/pending/parked/failed colours from `--bs-success/warning/danger`, `.fg-node.pending .fg-pill { animation: fg-pulse 1.6s ease-in-out infinite }` guarded by `prefers-reduced-motion`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/FlowGraphViewTests.cs`
**Contract:** Height = `4 × LaneHeight + LanePad` (the de-DE test's pinned `242.2` at zoom 0.7 becomes `319.2`). A `Response` step is placed at `max(all other columns) + 1` regardless of its predecessor's column, so linking it from the route call (D9) does not drag it back to column 2 while the site work sits to its right. Existing `External` dashed style is kept for the echo edge; `async` shares the dash but not the class. The lane sublabel for External lists up to three targets then `+n`.
**Tests:** four `g.fg-lane`; an `ApiCall` step lands in lane 3 with eyebrow `API CALL` and a pill `Ok`; a `Pending` step has classes `pending` and the pill text `Pending`; `Async` edge has class `async`, `Spawn` edge uses the spawn marker; de-DE height `319.2`. Commit — `git commit -am "feat(central-ui): FlowGraphView External lane, outbound status pills, async/spawn connectors"`.
## Task 8: `FlowStepDetail` — Lifecycle tab and outbound fields
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6, Task 7
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Deployment/FlowStepDetail.razor` (tabs list; new `Lifecycle` tab rendered only when `Step.Lifecycle is { Count: > 0 }`; *Where this ran* gains `System / Connection / List` (= `Target`) and `Delivered by` (= the terminal row's `Node`); Input tab labels `Request` (API), `SQL + parameters` (DB), `Subject + body` (Notify))
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowStepDetailTests.cs` (exists — extend)
**Contract:** Lifecycle table columns `time · kind · status · node · error`, newest last, `data-test="flow-step-lifecycle"`; a `Parked` step shows a hint line "Retry or discard from Site Calls / Notification report" (text only, links are deferred).
Commit — `git commit -am "feat(central-ui): FlowStepDetail Lifecycle tab + outbound request/response labels"`.
## Task 9: Flow Debugger page — pending refresh after a Live run
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on 7, 8)
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor` (status strip: `Pending n` count + `Refresh outcomes` button `data-test="flow-refresh"`, a small "outcomes still arriving — refreshing" note `data-test="flow-pending-note"` while polling), `FlowDebugger.razor.cs` (`RunAsync` ~353: after a successful run with any `Outcome == Pending` step start `RefreshPendingAsync()` — loop: `IFlowDebugService.ReplayAsync(root)`; if the returned graph has no Pending step or `PendingRefreshWindow` (2 min) elapsed, stop; else delay `PendingRefreshInterval` (5 s); merges by replacing `_graph`/`_raw` and keeping `_selected` by `EventId`; cancelled by a new run, a mode switch or `Dispose`)
- Test: `tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs`
**Contract:** Polling never runs in Replay mode; the manual button works in both modes; the state chip stays `Completed` (pending outbound work is not a failed run); the run's own Steps count updates as rows fold in.
**Tests (inject a fake delay via an internal `Func<TimeSpan, CancellationToken, Task>` as `FlowDebugRunner` does):** a Live outcome with one Pending step triggers `ReplayAsync(root)` calls until a graph with `Ok` returns; a graph with no Pending step never polls; `flow-refresh` click calls `ReplayAsync` once. Commit — `git commit -am "feat(central-ui): Flow Debugger refreshes pending outbound outcomes after a Live run"`.
## Task 10: CLI projection, docs, rig demo + verification
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CLI/Commands/FlowDebugTableProjection.cs:85-115` (fold rows by correlation like the builder — reuse `FlowGraphBuilder.Build` and project steps instead of raw events; add `lifecycle` column = `n rows`), `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` (`### flow-debug` table columns), test `tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/FlowDebugCommandsTests.cs`
- Modify: `docs/requirements/Component-CentralUI.md` (Flow Debugger bullet: four lanes, node anatomy, refresh), `Component-AuditLog.md` (scope table: central method-script DB calls now audited; Flow Debugger reads the three outbound channels), `Component-InboundAPI.md` (`Database.*` audit row), `Component-ScriptAnalysis.md` (REQ-SA-7 kinds), `docs/plans/2026-09-05-flow-debugger-design.md` (status line: "v1.1 outbound calls — see 2026-09-06 plan"), `CLAUDE.md` (Flow Debugger bullet: External lane + pending refresh), `docs/deferred-work-register.md` (row: Retry/Discard from the node + `?correlationId=` deep links deferred)
- Create: `docker/seed-flow-outbound-demo.sh` — a `Call` script `OutboundDemo` on `SimMachine` (site-a) that does `ExternalSystem.Call` against the rig's `scadabridge-restapi` (register an External System `RigRest` first), `Database.CachedWrite` to `scadabridge-mssql` (a `RigDb` connection, `CREATE TABLE IF NOT EXISTS FlowDemo`), `Notify.Send("FlowDemo")` (a list pointing at the rig SMTP), plus an inbound method `FlowOutboundDemo` routing to it and a scoped key
- Modify: `docker/verify-delmia-notifier.sh` — step **I**: `flow-debug run-method` on `FlowOutboundDemo` asserts kinds ⊇ {`ApiCall`,`DbCall`,`Notify`} inline and `Pending` for the two async ones; then `flow-debug replay` retried ≤ 90 s until both are terminal (`Delivered`); prints `I outbound live+replay -> YES`; `docker/README.md` one line
**Steps:** implement, `dotnet build ZB.MOM.WW.ScadaBridge.slnx`, per-project tests, `bash docker/deploy.sh`, seed the demo, run the drill, paste the I line into the commit — `git commit -am "feat(flow-debugger): outbound calls on the External lane — CLI projection, specs, rig demo + verification step I"`.
---
## Verification (whole plan)
1. `dotnet build ZB.MOM.WW.ScadaBridge.slnx -nologo -v q` clean.
2. `dotnet test` per project green (Playwright excluded).
3. Rig: `DELMIA_API_KEY=… bash docker/verify-delmia-notifier.sh` → H and I both `YES`.
4. Browser: `/deployment/flow-debugger?executionId=<I run root>` shows four lanes, the API call in-chain with `Ok · sync`, the DB write and notification hanging off the script run on dashed edges, both pills going amber→green within ~10 s of load (or after `Refresh outcomes`), and the Lifecycle tab listing Submitted → Forwarded → Delivered.