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
This commit is contained in:
Joseph Doherty
2026-09-06 01:33:41 -04:00
parent 038eda5d91
commit 3a933e3f68
3 changed files with 663 additions and 0 deletions
@@ -0,0 +1,328 @@
<title>Flow Debugger External Lane</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root {
--ground: #f4f3ee; --surface: #fbfaf7; --surface-2: #f0efe9;
--ink: #1f231f; --ink-soft: #5d625d; --ink-faint: #8a8f89;
--rule: #dcdbd3; --rule-strong: #c7c6bd;
--accent: #2f5fd0; --active-bg: #eef2fc;
--ok: #2e7d4a; --ok-bg: #e4f2e8; --warn: #b8741a; --warn-bg: #fbf0dc; --danger: #b3362d; --danger-bg: #f9e3e0;
--sans: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #1b1d1b; --surface: #242724; --surface-2: #2c2f2c;
--ink: #e8e8e1; --ink-soft: #b3b6b0; --ink-faint: #7f837e;
--rule: #383b37; --rule-strong: #4a4e49;
--accent: #7f9ff0; --active-bg: #23304d;
--ok: #7dcb95; --ok-bg: #1f3a2a; --warn: #e2b264; --warn-bg: #3f3117; --danger: #ee8b82; --danger-bg: #46231f;
}
}
:root[data-theme="dark"] {
--ground: #1b1d1b; --surface: #242724; --surface-2: #2c2f2c;
--ink: #e8e8e1; --ink-soft: #b3b6b0; --ink-faint: #7f837e;
--rule: #383b37; --rule-strong: #4a4e49;
--accent: #7f9ff0; --active-bg: #23304d;
--ok: #7dcb95; --ok-bg: #1f3a2a; --warn: #e2b264; --warn-bg: #3f3117; --danger: #ee8b82; --danger-bg: #46231f;
}
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
.page { max-width: 1180px; margin: 0 auto; padding: 28px 24px 56px; display: flex; flex-direction: column; gap: 22px; }
h1 { font-size: 1.45rem; font-weight: 600; margin: 0; letter-spacing: -0.01em; text-wrap: balance; }
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 8px; }
p { margin: 0; max-width: 68ch; }
.lede { color: var(--ink-soft); }
.eyebrow { font-size: 0.68rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-faint); }
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
a { color: var(--accent); }
/* ── the mocked screen ─────────────────────────────────────────────── */
.screen { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 14px 22px; align-items: flex-end; }
.field label { display: block; font-size: 0.72rem; color: var(--ink-soft); margin-bottom: 3px; }
.seg { display: inline-flex; border: 1px solid var(--rule-strong); border-radius: 4px; overflow: hidden; }
.seg span { padding: 4px 10px; font-size: 0.8rem; color: var(--ink-soft); background: var(--surface); }
.seg span.on { background: var(--accent); color: #fff; }
.select { border: 1px solid var(--rule-strong); border-radius: 4px; padding: 4px 28px 4px 8px; font-size: 0.8rem; background: var(--surface) linear-gradient(45deg, transparent 50%, var(--ink-faint) 50%) no-repeat right 12px top 55% / 6px 6px; min-width: 190px; }
.btn { border: 1px solid var(--rule-strong); border-radius: 4px; padding: 4px 12px; font-size: 0.8rem; background: var(--surface); color: var(--ink); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.spacer { flex: 1; }
.status { display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; padding: 8px 12px; border: 1px solid var(--rule); border-radius: 4px; background: var(--surface-2); font-size: 0.8rem; }
.status .k { color: var(--ink-faint); font-size: 0.68rem; letter-spacing: 0.06em; text-transform: uppercase; margin-right: 6px; }
.chip { border: 1px solid var(--ok); color: var(--ok); border-radius: 999px; padding: 1px 10px; font-size: 0.76rem; font-weight: 500; }
.note { color: var(--warn); display: inline-flex; align-items: center; gap: 6px; }
.note .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 1; box-shadow: 0 0 0 0 var(--warn-bg);} 50% { opacity: .6; box-shadow: 0 0 0 5px var(--warn-bg);} }
@media (prefers-reduced-motion: reduce) { .note .dot, .fg-node.pending .pill { animation: none !important; } }
.flow-wrap { border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); overflow-x: auto; }
svg.fg { display: block; width: 1370px; height: 456px; font-family: var(--sans); }
.lane-bg.alt { fill: var(--rule); fill-opacity: .25; } .lane-bg { fill: transparent; }
.lane-rule { stroke: var(--rule); stroke-width: 1; }
.lane-label { fill: var(--ink-soft); font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }
.lane-sub { fill: var(--ink-faint); font-size: 10.5px; font-family: var(--mono); }
.fg-node { cursor: pointer; }
.fg-node rect { fill: var(--surface); stroke: var(--rule-strong); stroke-width: 1; }
.fg-node.selected rect { stroke: var(--accent); stroke-width: 2; fill: var(--active-bg); }
.body { box-sizing: border-box; width: 100%; height: 100%; padding: 6px 8px 5px; display: flex; flex-direction: column; gap: 1px; overflow: hidden; line-height: 1.2; color: var(--ink); font-family: var(--sans); }
.head { display: flex; justify-content: space-between; align-items: baseline; gap: 6px; }
.eb { color: var(--ink-faint); font-size: 9.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; }
.dur { color: var(--ink-soft); font-size: 9.5px; font-family: var(--mono); white-space: nowrap; }
.title { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.sub { color: var(--ink-soft); font-size: 10.5px; margin-top: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pill { margin-top: auto; align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-weight: 500; padding: 1px 7px; border-radius: 999px; white-space: nowrap; }
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--ok); background: var(--ok-bg); }
.pill.pending { color: var(--warn); background: var(--warn-bg); animation: pulse 1.6s ease-in-out infinite; }
.pill.parked { color: var(--warn); background: var(--warn-bg); }
.pill.failed { color: var(--danger); background: var(--danger-bg); }
.edge { fill: none; stroke: var(--ink-faint); stroke-width: 1.5; }
.edge.boundary { stroke: var(--accent); }
.edge.async { stroke-dasharray: 5 4; }
.edge.echo { stroke-dasharray: 2 3; }
.arrow { fill: var(--ink-faint); } .arrow.accent { fill: var(--accent); }
.async-tag { fill: var(--ink-faint); font-size: 9.5px; font-family: var(--mono); }
/* ── detail panel ─────────────────────────────────────────────────── */
.detail { border: 1px solid var(--rule); border-radius: 4px; background: var(--surface); padding: 14px 16px; display: grid; grid-template-columns: 1fr 300px; gap: 18px 28px; }
.detail .hd { grid-column: 1 / -1; display: flex; align-items: flex-start; gap: 12px; }
.detail .hd h3 { margin: 2px 0 0; font-size: 1rem; font-weight: 600; }
.detail .hd .who { color: var(--ink-soft); font-size: 0.8rem; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--rule); margin-bottom: 10px; }
.tabs span { padding: 5px 12px; font-size: 0.82rem; color: var(--accent); }
.tabs span.on { color: var(--ink); border: 1px solid var(--rule); border-bottom-color: var(--surface); border-radius: 4px 4px 0 0; margin-bottom: -1px; background: var(--surface); }
table.life { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
table.life th { text-align: left; font-weight: 500; color: var(--ink-faint); font-size: 0.68rem; letter-spacing: .06em; text-transform: uppercase; padding: 4px 8px; border-bottom: 1px solid var(--rule); }
table.life td { padding: 6px 8px; border-bottom: 1px solid var(--rule); vertical-align: top; }
table.life tr:last-child td { border-bottom: 0; }
.kv { display: grid; grid-template-columns: 110px 1fr; gap: 6px 10px; font-size: 0.8rem; align-items: baseline; }
.kv .k { font-weight: 500; color: var(--ink-soft); }
.kv .v { font-family: var(--mono); font-size: 0.78rem; }
.btnrow { display: flex; gap: 8px; margin-top: 12px; }
.btn.sm { font-size: 0.74rem; padding: 3px 9px; }
/* ── explanatory sections ──────────────────────────────────────────── */
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 32px; }
@media (max-width: 860px) { .grid2, .detail { grid-template-columns: 1fr; } }
.legend { display: grid; grid-template-columns: 34px 1fr; gap: 8px 12px; align-items: center; font-size: 0.82rem; }
.legend svg { display: block; }
.legend .pill { margin: 0; font-size: 11px; }
table.rows { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
table.rows th { text-align: left; font-weight: 500; color: var(--ink-faint); font-size: 0.68rem; letter-spacing: .06em; text-transform: uppercase; padding: 4px 8px 6px; border-bottom: 1px solid var(--rule-strong); }
table.rows td { padding: 6px 8px; border-bottom: 1px solid var(--rule); vertical-align: top; }
table.rows code { font-family: var(--mono); font-size: 0.76rem; }
.table-wrap { overflow-x: auto; }
ol.plan { margin: 0; padding-left: 22px; display: grid; gap: 5px; font-size: 0.84rem; }
ol.plan b { font-weight: 600; }
.callout { border-left: 3px solid var(--accent); background: var(--surface-2); padding: 10px 14px; font-size: 0.84rem; border-radius: 0 4px 4px 0; }
</style>
<div class="page">
<header>
<div class="eyebrow">Flow Debugger · design addendum · 2026-09-06</div>
<h1>Outbound calls on a fourth lane</h1>
<p class="lede">What the swimlane looks like once <code class="mono">ExternalSystem</code>, <code class="mono">Database</code> and <code class="mono">Notify</code> calls become nodes. The flow below is the rig demo the plan seeds (<code class="mono">FlowOutboundDemo</code> → <code class="mono">DelmiaSim01.OutboundDemo</code>): one synchronous REST call, one cached database write, one notification.</p>
</header>
<!-- ══ the mocked page ══ -->
<section class="screen" aria-label="Flow Debugger mockup">
<div class="toolbar">
<div class="field"><label>Entry point</label><span class="seg"><span class="on">Inbound API method</span><span>Value-change trigger</span></span></div>
<div class="field"><label>Method</label><span class="select">FlowOutboundDemo</span></div>
<div class="field"><label>Mode</label><span class="seg"><span class="on">Live</span><span>Replay</span></span></div>
<div class="spacer"></div>
<span class="btn">Refresh outcomes</span>
<span class="btn primary">Run</span>
</div>
<div class="status">
<span class="chip">Completed</span>
<span><span class="k">Started</span><span class="mono">05:31:02.118Z</span></span>
<span><span class="k">Total</span><span class="mono">371 ms</span></span>
<span><span class="k">Steps</span><span class="mono">9</span></span>
<span><span class="k">Boundaries</span><span class="mono">2</span></span>
<span><span class="k">Writes</span><span class="mono">2</span></span>
<span><span class="k">Pending</span><span class="mono">1</span></span>
<span class="note"><span class="dot"></span>outcomes still arriving — refreshing every 5 s (1:40 left)</span>
</div>
<div class="flow-wrap">
<svg class="fg" id="fg" viewBox="0 0 1370 456" role="img" aria-label="Flow diagram: 9 steps on four lanes, 2 boundary crossings, 1 pending outbound call">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow"/></marker>
<marker id="ar-acc" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow accent"/></marker>
</defs>
</svg>
</div>
<!-- detail panel: the cached DB write is selected -->
<div class="detail">
<div class="hd">
<div>
<div class="eyebrow">DbCall · External</div>
<h3>RigDb · CachedWrite</h3>
<div class="who">DelmiaSim01 · OutboundDemo</div>
</div>
<div class="spacer"></div>
<span class="pill ok">Delivered</span>
</div>
<div>
<div class="tabs"><span>SQL + parameters</span><span>Result</span><span class="on">Lifecycle</span><span>Timing</span></div>
<table class="life">
<thead><tr><th>Time</th><th>Row</th><th>Status</th><th>Node</th><th>Error</th></tr></thead>
<tbody>
<tr><td class="mono">05:31:02.301</td><td>CachedSubmit</td><td>Submitted</td><td class="mono">site-a / node-a</td><td>—</td></tr>
<tr><td class="mono">+0.9 s</td><td>DbWriteCached</td><td>Forwarded</td><td class="mono">node-a</td><td>—</td></tr>
<tr><td class="mono">+4.1 s</td><td>CachedResolve</td><td>Delivered</td><td class="mono">node-a</td><td>—</td></tr>
</tbody>
</table>
</div>
<div>
<div class="eyebrow" style="margin-bottom:8px">Where this ran</div>
<div class="kv">
<span class="k">Site / node</span><span class="v">site-a / node-a</span>
<span class="k">Instance</span><span class="v">DelmiaSim01</span>
<span class="k">Connection</span><span class="v">RigDb</span>
<span class="k">Operation</span><span class="v">7c2e91d4…</span>
<span class="k">Delivered by</span><span class="v">node-a · +4.1 s</span>
</div>
<div class="btnrow"><span class="btn sm">Open in Debug View</span><span class="btn sm">Audit rows (3)</span></div>
</div>
</div>
</section>
<!-- ══ legend + node anatomy ══ -->
<section class="grid2">
<div>
<h2>Connectors and pills</h2>
<div class="legend">
<svg width="34" height="12"><path d="M0 6H30" class="edge" marker-end="url(#ar)"/></svg><span>Executed edge — the run continued here.</span>
<svg width="34" height="12"><path d="M0 6H30" class="edge boundary" marker-end="url(#ar-acc)"/></svg><span>Crossed the central ↔ site gRPC boundary.</span>
<svg width="34" height="12"><path d="M0 6H30" class="edge async" marker-end="url(#ar)"/></svg><span><b>New.</b> Async branch — <code class="mono">CachedCall</code> / <code class="mono">CachedWrite</code> / <code class="mono">Notify.Send</code> returned at once; the run carried on without it.</span>
<svg width="34" height="12"><path d="M0 6H30" class="edge echo" marker-end="url(#ar)"/></svg><span>Inferred device echo (a write that satisfied a wait).</span>
<svg width="34" height="14"><path d="M0 7H26" class="edge" /><path d="M20 1l-4 6h5l-3 6 7-8h-5l3-4z" class="arrow"/></svg><span><b>New.</b> Trigger spawn — this write fired a value-change script.</span>
<span class="pill ok">Ok</span><span>Sync call answered, or cached operation delivered.</span>
<span class="pill pending">Pending</span><span>Submitted / forwarded, not yet resolved. The page keeps refreshing for up to two minutes after a Live run.</span>
<span class="pill parked">Parked</span><span>Retries exhausted or permanent failure downstream; retry or discard from Site Calls / Notifications.</span>
<span class="pill failed">Failed</span><span>A sync call failed and the script received the error.</span>
</div>
</div>
<div>
<h2>What changed in the graph</h2>
<div class="callout">
<p style="max-width:none"><b>External is a lane, not a caller-side node.</b> Like Tags, it holds actors outside ScadaBridge — a REST endpoint, a database, the mail relay — so a hop that leaves the system reads as one. Its gutter lists the targets touched.</p>
<p style="max-width:none;margin-top:8px"><b>One node per operation.</b> A cached call's four audit rows (Submitted → Forwarded → Attempted → Delivered) fold into one node; the pill is the latest status and the Lifecycle tab is the history. A notification folds the site's <code class="mono">NotifySend</code> with central's <code class="mono">NotifyDeliver</code> rows.</p>
<p style="max-width:none;margin-top:8px"><b>Sync calls sit in the chain; async ones branch.</b> The script blocks on <code class="mono">ExternalSystem.Call</code>, so the chain runs through it (write → API call → write). It does not block on the cached write or the notification, so those hang off the script run on a dashed edge and the chain skips them.</p>
<p style="max-width:none;margin-top:8px"><b>The Response now starts from the route call</b>, not from whatever step happened last — a cascaded write on another instance is not what answered the HTTP request.</p>
</div>
</div>
</section>
<!-- ══ rows → nodes ══ -->
<section>
<h2>How audit rows become nodes</h2>
<div class="table-wrap">
<table class="rows">
<thead><tr><th>Script call</th><th>Audit rows already written</th><th>Node</th><th>Live inline</th><th>Arrives later</th></tr></thead>
<tbody>
<tr><td><code>ExternalSystem.Call</code></td><td><code>ApiOutbound / ApiCall</code> Delivered|Failed, request + response, duration</td><td>API CALL · in chain</td><td>whole node</td><td>—</td></tr>
<tr><td><code>ExternalSystem.CachedCall</code></td><td><code>CachedSubmit</code> → <code>ApiCallCached</code> Forwarded / Attempted ×n → <code>CachedResolve</code></td><td>API CALL · async branch</td><td>Submitted</td><td>Forwarded, attempts, Delivered / Parked</td></tr>
<tr><td><code>Database.Query / Execute</code></td><td><code>DbOutbound / DbWrite</code>, <code>Extra {op, rows…}</code></td><td>DATABASE · in chain</td><td>whole node</td><td>—</td></tr>
<tr><td><code>Database.CachedWrite</code></td><td><code>CachedSubmit</code> → <code>DbWriteCached</code> → <code>CachedResolve</code></td><td>DATABASE · async branch</td><td>Submitted</td><td>the rest</td></tr>
<tr><td><code>Notify.Send</code></td><td>site <code>NotifySend</code> Submitted → central <code>NotifyDeliver</code> Attempted / Delivered / Parked</td><td>NOTIFY · async branch</td><td>Submitted</td><td>central outbox rows</td></tr>
<tr><td>central method script <code>Database.*</code></td><td><b>none today</b> → gets <code>DbOutbound / DbWrite</code> (closes an audit gap)</td><td>DATABASE · in chain, Central-side caller</td><td>whole node</td><td>—</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ══ plan at a glance ══ -->
<section class="grid2">
<div>
<h2>Plan at a glance</h2>
<ol class="plan" start="0">
<li><b>Commons types</b> — kinds <code class="mono">ApiCall / DbCall / Notify</code>, lane <code class="mono">External</code>, outcomes <code class="mono">Pending / Parked</code>, <code class="mono">FlowLifecycleRow</code>.</li>
<li><b>Projector</b> maps the three outbound channels onto the External lane.</li>
<li><b>Graph builder</b> folds lifecycles into one node, async branch edges, spawn edges, Response from the route call.</li>
<li><b>Site runtime</b> routes outbound rows through the existing trace collector.</li>
<li><b>Central</b> audits method-script <code class="mono">Database.*</code> and feeds the trace sink.</li>
<li><b>Replay</b> filter widens to the three channels; site pull verified.</li>
<li><b>Predictor</b> emits the three kinds with async edges.</li>
<li><b>FlowGraphView</b> — fourth lane, pills, async/spawn connectors.</li>
<li><b>Step detail</b> — Lifecycle tab, request/response labels.</li>
<li><b>Page</b> — refresh pending outcomes after a Live run.</li>
<li><b>CLI, specs, rig demo</b> + verification step I.</li>
</ol>
</div>
<div>
<h2>Not in this slice</h2>
<p>Retry / Discard on a parked node (Site Calls and the Notification report own those actions), deep links by operation id into those pages, and per-attempt request diffs. Each is a register row, not a blocker.</p>
<p style="margin-top:10px">Repo plan: <code class="mono">docs/plans/2026-09-06-flow-debugger-outbound-calls.md</code>.</p>
</div>
</section>
</div>
<script>
(function () {
const LH = 110, NW = 150, NH = 84, X0 = 160, PITCH = 170, PAD = 16;
const lanes = [
{ name: 'Central', sub: '' },
{ name: 'Site', sub: 'site-a · DelmiaSim01' },
{ name: 'Tags', sub: 'DelmiaSim01' },
{ name: 'External', sub: 'RigRest · RigDb · FlowDemo' },
];
// lane, column, eyebrow, duration, title, subtitle | pill
const nodes = {
req: { lane: 0, col: 0, eb: 'Request', dur: '13 ms', title: 'FlowOutboundDemo', sub: 'multi-role' },
route: { lane: 0, col: 1, eb: 'Route call', dur: '358 ms', title: 'DelmiaSim01.OutboundDemo', sub: '→ 9b1c02aa' },
run: { lane: 1, col: 2, eb: 'Script run', dur: '341 ms', title: 'OutboundDemo', sub: 'DelmiaSim01 · call' },
w1: { lane: 2, col: 3, eb: 'Write', dur: '0 ms', title: 'RecipeProcessedFlag', sub: 'RecipeProcessedFlag = True' },
api: { lane: 3, col: 4, eb: 'API call', dur: '212 ms', title: 'RigRest.AckRecipe', pill: ['ok', 'Ok · 200 · sync'] },
w2: { lane: 2, col: 5, eb: 'Write', dur: '0 ms', title: 'LastAckAt', sub: 'LastAckAt = 05:31:02.5' },
db: { lane: 3, col: 3, eb: 'Database', dur: '+4.1 s', title: 'RigDb · CachedWrite', pill: ['ok', 'Delivered · cached'], selected: true },
ntf: { lane: 3, col: 5, eb: 'Notify', dur: '', title: 'FlowDemo', pill: ['pending', 'Pending · submitted'] },
resp: { lane: 0, col: 6, eb: 'Response', dur: '', title: 'Response', sub: '200' },
};
const edges = [
['req', 'route', ''], ['route', 'run', 'boundary'], ['run', 'w1', ''], ['w1', 'api', ''], ['api', 'w2', ''],
['run', 'db', 'async'], ['run', 'ntf', 'async'], ['route', 'resp', ''],
];
const svg = document.getElementById('fg');
const NS = 'http://www.w3.org/2000/svg';
const el = (t, a, parent) => { const e = document.createElementNS(NS, t); for (const k in a) e.setAttribute(k, a[k]); (parent || svg).appendChild(e); return e; };
const W = 1370;
lanes.forEach((l, i) => {
el('rect', { x: 0, y: i * LH, width: W, height: LH, class: 'lane-bg' + (i % 2 ? ' alt' : '') });
el('line', { x1: 0, y1: i * LH, x2: W, y2: i * LH, class: 'lane-rule' });
el('text', { x: 12, y: i * LH + 24, class: 'lane-label' }).textContent = l.name;
if (l.sub) el('text', { x: 12, y: i * LH + 40, class: 'lane-sub' }).textContent = l.sub;
});
for (const k in nodes) { const n = nodes[k]; n.x = X0 + n.col * PITCH; n.y = PAD + n.lane * LH; }
const path = (a, b) => {
const x1 = a.x + NW, y1 = a.y + NH / 2, x2 = b.x, y2 = b.y + NH / 2;
if (Math.abs(y1 - y2) < .01) return `M ${x1} ${y1} H ${x2}`;
const mid = x1 + (x2 - x1) / 2;
return `M ${x1} ${y1} H ${mid} V ${y2} H ${x2}`;
};
// Async branches leave the source's bottom edge, run along the strip above the target
// lane's nodes and drop into the target's top — so they never cross a node in between.
const asyncPath = (a, b) => `M ${a.x + NW / 2} ${a.y + NH} V ${b.y - 8} H ${b.x + 16} V ${b.y}`;
edges.forEach(([f, t, cls]) => {
const d = cls === 'async' ? asyncPath(nodes[f], nodes[t]) : path(nodes[f], nodes[t]);
el('path', { d, class: 'edge ' + cls, 'marker-end': cls === 'boundary' ? 'url(#ar-acc)' : 'url(#ar)' });
if (cls === 'async') el('text', { x: nodes[t].x + 22, y: nodes[t].y - 11, class: 'async-tag' }).textContent = 'async';
});
for (const k in nodes) {
const n = nodes[k];
const g = el('g', { class: 'fg-node' + (n.selected ? ' selected' : '') + (n.pill && n.pill[0] === 'pending' ? ' pending' : ''), transform: `translate(${n.x} ${n.y})`, role: 'button', tabindex: 0 });
el('rect', { x: 0, y: 0, width: NW, height: NH, rx: 4, ry: 4 }, g);
const fo = el('foreignObject', { x: 0, y: 0, width: NW, height: NH }, g);
const body = document.createElement('div'); body.className = 'body'; body.title = n.title + (n.sub ? ' — ' + n.sub : '');
const bottom = n.pill ? `<span class="pill ${n.pill[0]}">${n.pill[1]}</span>` : `<div class="sub">${n.sub || ''}</div>`;
body.innerHTML = `<div class="head"><span class="eb">${n.eb}</span><span class="dur">${n.dur}</span></div><div class="title">${n.title.replace(/([a-z])([A-Z])/g, '$1​$2').replace(/\./g, '.​')}</div>${bottom}`;
fo.appendChild(body);
}
})();
</script>
@@ -0,0 +1,235 @@
# 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.
@@ -0,0 +1,100 @@
{
"planPath": "docs/plans/2026-09-06-flow-debugger-outbound-calls.md",
"tasks": [
{
"id": 0,
"subject": "Task 0: Commons \u2014 kinds, lane, outcomes, lifecycle row",
"status": "pending"
},
{
"id": 1,
"subject": "Task 1: Commons \u2014 FlowStepProjector maps outbound rows",
"status": "pending",
"blockedBy": [
0
]
},
{
"id": 2,
"subject": "Task 2: Commons \u2014 FlowGraphBuilder groups operations, async branches, spawn edges, Response origin",
"status": "pending",
"blockedBy": [
0,
1
]
},
{
"id": 3,
"subject": "Task 3: Site runtime \u2014 outbound rows flow through the trace collector",
"status": "pending",
"blockedBy": [
0
]
},
{
"id": 4,
"subject": "Task 4: Central \u2014 audit InboundDatabaseHelper (DbOutbound) and feed the trace sink",
"status": "pending",
"blockedBy": [
0
]
},
{
"id": 5,
"subject": "Task 5: Central replay filter + site pull verification",
"status": "pending",
"blockedBy": [
0
]
},
{
"id": 6,
"subject": "Task 6: FlowShapePredictor \u2014 three kinds on the External lane, async edges",
"status": "pending",
"blockedBy": [
0
]
},
{
"id": 7,
"subject": "Task 7: FlowGraphView \u2014 External lane, status pills, async/spawn edge styles, pending state",
"status": "pending",
"blockedBy": [
0,
1,
2
]
},
{
"id": 8,
"subject": "Task 8: FlowStepDetail \u2014 Lifecycle tab and outbound fields",
"status": "pending",
"blockedBy": [
0,
2
]
},
{
"id": 9,
"subject": "Task 9: Flow Debugger page \u2014 pending refresh after a Live run",
"status": "pending",
"blockedBy": [
7,
8
]
},
{
"id": 10,
"subject": "Task 10: CLI projection, docs, rig demo + verification",
"status": "pending",
"blockedBy": [
3,
4,
5,
6,
9
]
}
],
"lastUpdated": "2026-09-06T05:32:15.141670+00:00"
}