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
29 KiB
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.ResolveKindalready mapsApiOutbound/DbOutbound/Notificationrows toFlowStepKind.External, andFlowShapePredictoralready emitsExternalnodes forExternalSystem.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 forAttributeWrite/AttributeWaitonly (theScriptRunrow is added byScriptRunLauncher). The outbound rows are built atScriptRuntimeContext.cs:1286(ESCachedSubmit),:1404(ApiCallCachedAttempted),:1472(CachedResolve),:1651(syncApiCall),:2015/:2067(DB cached rows),:2518(NotifySend) and inAuditingDbCommand.cs:479(syncDbWrite) — all go straight to the siteIAuditWriter. - Replay filters the tree to
Channel == ScriptTrace || (ApiInbound && InboundRequest)(AuditLogRepository.csGetTraceEventsAsync, the.Whereright 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
Databasehelper (InboundScriptExecutor.cs:554,InboundDatabaseHelper.cs) that writes no audit row at all — a real audit-scope gap (Component-AuditLog.mdsays 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; recordFlowStepat ~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(wholeBuild; 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).
- Chain within execution skipping async steps (kind
Notify, or first lifecycle row kindCachedSubmit); for each skipped step addFlowEdge(prev → step, Async: true)whereprevis the chain step immediately before it in time. - Spawner link unchanged, but the edge is
Spawn: truewhen the spawner is not aRouteCall. - Satisfied-wait echo edge unchanged (
External: true). - Response: linked from the last step with
ExecutionId == inbound.ExecutionIdand kind ≠ Response (falls back to global last when none).KindRank:ApiCall/DbCall/Notify= 5 (same slotExternalhad).
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(EmitTrace363-392 — extract aprivate 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:147andAuditingDbCommand.cs:70-90, 479— add an optionalAction<AuditEvent>? onAuditctor parameter threaded from the context'sCollect - 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 withexecutionId,parentExecutionId, the request's method name as actor context,ICentralAuditWriterfrom 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:861ReadByExecutionIdsAsync(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(theExternalSystem/Database/Notifybranches;PredictedEdgegainsAsync) - 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 loopfor lane < 3→LaneCount; node body: add a status pill row for the three kinds; edge classes),FlowGraphView.razor.cs(LaneCount = 4;Eyebrowfor the three kinds; sublabel for External = distinctTargets joined with·, clipped;EdgeClassaddsasync,spawn;NodeClassaddspending,parked;MarkerForreturnsurl(#fg-arrow-spawn)for spawn edges),FlowGraphView.razor.css(.fg-edge.async { stroke-dasharray: 5 4 },.fg-edge.spawnmarker path with a small bolt,.fg-pillok/pending/parked/failed colours from--bs-success/warning/danger,.fg-node.pending .fg-pill { animation: fg-pulse 1.6s ease-in-out infinite }guarded byprefers-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; newLifecycletab rendered only whenStep.Lifecycle is { Count: > 0 }; Where this ran gainsSystem / Connection / List(=Target) andDelivered by(= the terminal row'sNode); Input tab labelsRequest(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 ncount +Refresh outcomesbuttondata-test="flow-refresh", a small "outcomes still arriving — refreshing" notedata-test="flow-pending-note"while polling),FlowDebugger.razor.cs(RunAsync~353: after a successful run with anyOutcome == Pendingstep startRefreshPendingAsync()— loop:IFlowDebugService.ReplayAsync(root); if the returned graph has no Pending step orPendingRefreshWindow(2 min) elapsed, stop; else delayPendingRefreshInterval(5 s); merges by replacing_graph/_rawand keeping_selectedbyEventId; cancelled by a new run, a mode switch orDispose) - 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 — reuseFlowGraphBuilder.Buildand project steps instead of raw events; addlifecyclecolumn =n rows),src/ZB.MOM.WW.ScadaBridge.CLI/README.md(### flow-debugtable columns), testtests/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— aCallscriptOutboundDemoonSimMachine(site-a) that doesExternalSystem.Callagainst the rig'sscadabridge-restapi(register an External SystemRigRestfirst),Database.CachedWritetoscadabridge-mssql(aRigDbconnection,CREATE TABLE IF NOT EXISTS FlowDemo),Notify.Send("FlowDemo")(a list pointing at the rig SMTP), plus an inbound methodFlowOutboundDemorouting to it and a scoped key - Modify:
docker/verify-delmia-notifier.sh— step I:flow-debug run-methodonFlowOutboundDemoasserts kinds ⊇ {ApiCall,DbCall,Notify} inline andPendingfor the two async ones; thenflow-debug replayretried ≤ 90 s until both are terminal (Delivered); printsI outbound live+replay -> YES;docker/README.mdone 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)
dotnet build ZB.MOM.WW.ScadaBridge.slnx -nologo -v qclean.dotnet testper project green (Playwright excluded).- Rig:
DELMIA_API_KEY=… bash docker/verify-delmia-notifier.sh→ H and I bothYES. - Browser:
/deployment/flow-debugger?executionId=<I run root>shows four lanes, the API call in-chain withOk · 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 afterRefresh outcomes), and the Lifecycle tab listing Submitted → Forwarded → Delivered.