Files
ScadaBridge/docs/plans/2026-09-06-flow-debugger-outbound-calls.md
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

29 KiB
Raw Permalink Blame History

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
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 Targets 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.